// This module provides a framework for triggering notifications
// within the Apostrophe admin UI. Notifications may be triggered
// either on the browser or the server side, via `apos.notify`.
//
// ## Options
//
// ### `queryInterval`: interval in milliseconds between MongoDB
// queries while long polling for notifications. Defaults to 500
// (1/2 second). Set it longer if you prefer fewer queries, however
// these are indexed queries on a small amount of information and
// should not significantly impact your app.
//
// ### `longPollingTimeout`: maximum lifetime in milliseconds of a long
// polling HTTP request before a response with no notifications is sent.
// Defaults to 10000 (10 seconds) to avoid typical proxy server timeouts.
// Until it times out the request will keep making MongoDB queries to
// see if any new notifications are available (long polling).

const delay = require('bluebird').delay;

module.exports = {
  options: {
    alias: 'notification'
  },
  extend: '@apostrophecms/module',
  async init(self) {
    self.apos.notify = self.trigger;
    await self.ensureCollection();
    self.enableBrowserData();
  },
  restApiRoutes: (self) => ({
    // Poll for active notifications. Responds with:
    //
    // `{ notifications: [ ... ], dismissed: [ id1... ] }`
    //
    // Each notification has an `html` property containing
    // its rendered, localized markup, as well as `_id`, `createdAt`
    // and `id` (if one was provided when it was triggered).
    //
    // The client should provide `modifiedOnOrSince` and `seenIds` in the
    // query. `modifiedOnOrSince` is the timestamp of the most recent
    // notification modification time (updatedAt) the client has already seen,
    // and `seenIds` must contain the _ids of the notifications with that
    // exact notification time that the client has already seen, for
    // disambiguation.
    //
    // Waits up to 10 seconds for new notifications (long polling),
    // but then responds with an empty array to avoid proxy server timeouts.
    getAll: {
      before: 'middleware:@apostrophecms/global',
      async route(req) {
        let modifiedOnOrSince;
        if (!(req.user && req.user._id)) {
          throw self.apos.error('invalid');
        }
        const start = Date.now();
        try {
          modifiedOnOrSince = req.query.modifiedOnOrSince &&
            new Date(req.query.modifiedOnOrSince);
        } catch (e) {
          throw self.apos.error('invalid');
        }
        const seenIds = req.query.seenIds && self.apos.launder.ids(req.query.seenIds);
        return await attempt();

        async function attempt() {
          if (Date.now() - start >= (self.options.longPollingTimeout || 10000)) {
            return {
              notifications: [],
              dismissed: []
            };
          }

          const { notifications, dismissed } = await self.find(req, {
            modifiedOnOrSince,
            seenIds
          });
          if (!notifications.length && !dismissed.length) {
            await delay(self.options.queryInterval || 1000);
            return attempt();
          }

          return {
            notifications,
            dismissed
          };
        }
      }
    },
    getOne(req, _id) {
      return self.find(req, { displayingIds: [ _id ] });
    },
    async post(req) {
      const type = self.apos.launder.select(req.body.type, [
        'danger',
        'error',
        'warning',
        'success',
        'info',
        'progress'
      ], 'info');
      const icon = self.apos.launder.string(req.body.icon);
      const message = self.apos.launder.string(req.body.message);
      const classes = self.apos.launder.strings(req.body.classes);
      const interpolate = launderInterpolate(req.body.interpolate);
      const dismiss = self.apos.launder.integer(req.body.dismiss);
      let buttons = req.body.buttons;
      if (!Array.isArray(buttons)) {
        buttons = null;
      } else {
        buttons = buttons.filter(button => {
          return (button.type === 'event') &&
          ((typeof button.name) === 'string') &&
          ((typeof button.label) === 'string') &&
          ((button.data == null) || (((typeof button.data) === 'object') && (!Array.isArray(button.data))));
        }).map(button => ({
          name: button.name,
          data: button.data,
          label: button.label,
          type: button.type
        }));
      }
      return self.trigger(req, message, {
        classes,
        interpolate,
        dismiss,
        icon,
        type,
        buttons
      });

      function launderInterpolate(input) {
        if ((input == null) || ((typeof input) !== 'object')) {
          return {};
        }
        const interpolate = {};
        for (const [ key, val ] of Object.entries(input)) {
          if (key === 'count') {
            // Has a special status in i18next
            interpolate[key] = self.apos.launder.integer(val);
          } else {
            interpolate[key] = self.apos.launder.string(val);
          }
        }
        return interpolate;
      }
    },
    put(req, _id) {
      throw self.apos.error('unimplemented');
    },
    async patch(req, _id) {
      const dismissed = self.apos.launder.boolean(req.body.dismissed);
      if (dismissed) {
        await self.emit('beforeSave', req, {
          _id,
          dismissed
        });

        await self.db.updateOne({ _id }, {
          $set: {
            dismissed
          },
          $currentDate: {
            updatedAt: true
          }
        });
      }
    },
    async delete(req, _id) {
      await self.db.deleteMany({ _id });
    }
  }),
  apiRoutes(self) {
    return {
      post: {
        // Clear a registered event on a notification to prevent it from
        // emitting twice. Returns `true` if the event was found and cleared.
        // Returns `false` if not found (because it was already cleared).
        ':_id/clear-event': async function (req) {
          const lockId = `clear-event-${req.params._id}`;

          let response;
          try {
            await self.apos.lock.lock(lockId);

            response = await self.db.updateOne({
              _id: req.params._id,
              event: {
                $ne: null
              }
            }, {
              $set: {
                event: null
              }
            });
          } catch (error) {
            throw self.apos.error('notfound');
          } finally {
            await self.apos.lock.unlock(lockId);
          }

          return response.result.nModified > 0;
        }
      }
    };
  },
  methods(self) {
    return {
      getBrowserData(req) {
        return {
          action: self.action
        };
      },
      // When used server-side, call with `req` as the first argument,
      // or if you do not have a `req` it is acceptable to pass a user `_id`
      // string in place of `req`. Someone must be the recipient.
      //
      // When called client-side, there is no req argument because the
      // recipient is always the current user.
      //
      // The `message` argument should be a key that exists in a localization
      // file. If it does not, it will be displayed directly as a fallback.
      //
      // The `options.type` styles the notification and may be set to `error`,
      // `danger`, `warning`, `info` or `success`. If not set, a "plain" default
      // style is used.
      //
      // If `options.dismiss` is set to `true`, the message will auto-dismiss
      // after 5 seconds. If it is set to a number of seconds, it will dismiss
      // after that number of seconds. Otherwise it will not dismiss unless
      // clicked.
      //
      // If `options.buttons` is present, it must be an array of objects
      // with `type` and `label` properties. If `type` is `'event'` then the
      // object must have `name` and `data` properties, and when clicked the
      // button will trigger an apos bus event of the given `name` with the
      // provided `data` object. Currently `'event'` is the only supported value
      // for `type`.
      //
      // `options.return` will return the notification object. This is not
      // done otherwise to minimize risk of leaking MongoDB metadata to the
      // browser.
      //
      // `options.icon`, set to an active Vue Materials Icons icon name, will
      // set an icon on the notification.
      //
      // `options.job` can be set to an object with properties related to an
      // Apostrophe Job (from the @apostrophecms/job module) for the
      // notification to track the job's progress. These can include the job
      // `_id` and, for the 'completed' stage, the job `action`,
      // e.g., 'archive'.
      //
      // Throws an error if there is no `req.user`.
      //
      // `interpolate` may contain an object with properties to be
      // interpolated into the message via i18next. These can also
      // be passed via `options.interpolate`.
      //
      // This method is aliased as `apos.notify` for convenience.
      //
      // The method is async, and you may `await` to be certain the
      // notification has reached the database, but this is not mandatory.
      // It is a good idea when triggering a notification just before exiting
      // the application, as in a command line task.

      async trigger(req, message, options = {}, interpolate = {}) {
        if (typeof req === 'string') {
          // String was passed, assume it is a user _id
          req = { user: { _id: req } };
        }
        if (!req.user) {
          throw self.apos.error('forbidden');
        }
        if (!message) {
          throw self.apos.error('required');
        }

        req.body = req.body || {};

        const notification = {
          _id: self.apos.util.generateId(),
          createdAt: new Date(),
          userId: req.user._id,
          message,
          icon: options.icon,
          interpolate: interpolate || options.interpolate || {},
          // Defaults to true, otherwise launder as boolean
          localize: has(req.body, 'localize')
            ? self.apos.launder.boolean(req.body.localize)
            : true,
          job: options.job || null,
          event: options.event,
          classes: options.classes || null
        };

        if (options.dismiss === true) {
          options.dismiss = 5;
        }

        Object.assign(notification, options);

        await self.emit('beforeSave', req, notification);

        // We await here rather than returning because we expressly do not
        // want to leak mongodb metadata to the browser
        await self.db.updateOne(
          notification,
          {
            $set: notification,
            $currentDate: {
              updatedAt: true
            }
          }, {
            upsert: true
          }
        );

        return {
          noteId: notification._id
        };
      },

      // The dismiss method accepts the following arguments:
      // - req: A valid req.
      // - noteId: The _id of an active notification.
      // - delay: An optional integer of milliseconds to pause before the
      //   notification actually dismisses.
      async dismiss (req, noteId, delay) {
        if (!req.user) {
          throw self.apos.error('forbidden');
        }

        await pause(delay);

        try {
          await self.emit('beforeSave', req, {
            _id: noteId,
            dismissed: true
          });

          await self.db.updateOne(
            {
              _id: noteId
            },
            {
              $set: {
                dismissed: true
              },
              $currentDate: {
                updatedAt: true
              }
            }, {
              upsert: true
            }
          );
        } catch (error) {
          // Most likely the ID did not belong to an actual notification.
          throw self.apos.error('invalid');
        }

        async function pause (delay) {
          if (!delay) {
            return;
          }

          return new Promise((resolve) => setTimeout(resolve, delay));
        }
      },
      // Resolves with an object with `notifications` and `dismissed`
      // properties.
      //
      // If `options.modifiedOnOrSince` is set, notifications
      // greater than the timestamp are sent,
      // minus any notifications whose IDs are in `options.seenIds`.

      async find(req, options) {
        try {
          const results = await self.db.find({
            userId: req.user._id,
            ...(options.modifiedOnOrSince && {
              updatedAt: {
                $gt: new Date(options.modifiedOnOrSince)
              }
            }),
            ...(options.seenIds && {
              _id: {
                $nin: options.seenIds
              }
            })
          }).sort({ createdAt: 1 }).toArray();

          const notifications = results.filter(result => !result.dismissed);
          const dismissed = results.filter(result => result.dismissed);
          dismissed.forEach(element => {
            // 5-minute delay before deleting
            setTimeout(() => self.restApiRoutes.delete(req, element._id), 300000);
          });

          return {
            notifications,
            dismissed
          };
        } catch (err) {
          if (self.apos.db.closed) {
            // The database connection was intentionally closed,
            // which often triggers a race condition with
            // long polling requests. Send an empty response
            return {
              notifications: [],
              dismissed: []
            };
          } else {
            throw err;
          }
        }
      },

      async ensureCollection() {
        self.db = self.apos.db.collection('aposNotifications');
        await self.db.createIndex({
          userId: 1,
          createdAt: 1
        });
      }
    };
  }
};

function has(o, k) {
  return Object.prototype.hasOwnProperty.call(o, k);
}
