'use strict';

const DB = require('./DB');
const Store = require('express-session').Store;
const log4js = require('log4js');
const util = require('util');

const logger = log4js.getLogger('SessionStore');

class SessionStore extends Store {
  /**
   * @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's
   *     database record with the cookie's latest expiration time. If the difference between the
   *     value saved in the database and the actual value is greater than this amount, the database
   *     record will be updated to reflect the actual value. Use this to avoid continual database
   *     writes caused by express-session's rolling=true feature (see
   *     https://github.com/expressjs/session#rolling). A good value is high enough to keep query
   *     rate low but low enough to avoid annoying premature logouts (session invalidation) if
   *     Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record.
   *     Ignored if the cookie does not expire.
   */
  constructor(refresh = null) {
    super();
    this._refresh = refresh;
    // Maps session ID to an object with the following properties:
    //   - `db`: Session expiration as recorded in the database (ms since epoch, not a Date).
    //   - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or
    //     equal to `db`.
    //   - `timeout`: Timeout ID for a timeout that will clean up the database record.
    this._expirations = new Map();
  }

  shutdown() {
    for (const {timeout} of this._expirations.values()) clearTimeout(timeout);
  }

  async _updateExpirations(sid, sess, updateDbExp = true) {
    const exp = this._expirations.get(sid) || {};
    clearTimeout(exp.timeout);
    const {cookie: {expires} = {}} = sess || {};
    if (expires) {
      const sessExp = new Date(expires).getTime();
      if (updateDbExp) exp.db = sessExp;
      exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp);
      const now = Date.now();
      if (exp.real <= now) return await this._destroy(sid);
      // If reading from the database, update the expiration with the latest value from touch() so
      // that touch() appears to write to the database every time even though it doesn't.
      if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON();
      // Use this._get(), not this._destroy(), to destroy the DB record for the expired session.
      // This is done in case multiple Etherpad instances are sharing the same database and users
      // are bouncing between the instances. By using this._get(), this instance will query the DB
      // for the latest expiration time written by any of the instances, ensuring that the record
      // isn't prematurely deleted if the expiration time was updated by a different Etherpad
      // instance. (Important caveat: Client-side database caching, which ueberdb does by default,
      // could still cause the record to be prematurely deleted because this instance might get a
      // stale expiration time from cache.)
      exp.timeout = setTimeout(() => this._get(sid), exp.real - now);
      this._expirations.set(sid, exp);
    } else {
      this._expirations.delete(sid);
    }
    return sess;
  }

  async _write(sid, sess) {
    await DB.set(`sessionstorage:${sid}`, sess);
  }

  async _get(sid) {
    logger.debug(`GET ${sid}`);
    const s = await DB.get(`sessionstorage:${sid}`);
    return await this._updateExpirations(sid, s);
  }

  async _set(sid, sess) {
    logger.debug(`SET ${sid}`);
    sess = await this._updateExpirations(sid, sess);
    if (sess != null) await this._write(sid, sess);
  }

  async _destroy(sid) {
    logger.debug(`DESTROY ${sid}`);
    clearTimeout((this._expirations.get(sid) || {}).timeout);
    this._expirations.delete(sid);
    await DB.remove(`sessionstorage:${sid}`);
  }

  // Note: express-session might call touch() before it calls set() for the first time. Ideally this
  // would behave like set() in that case but it's OK if it doesn't -- express-session will call
  // set() soon enough.
  async _touch(sid, sess) {
    logger.debug(`TOUCH ${sid}`);
    sess = await this._updateExpirations(sid, sess, false);
    if (sess == null) return; // Already expired.
    const exp = this._expirations.get(sid);
    // If the session doesn't expire, don't do anything. Ideally we would write the session to the
    // database if it didn't already exist, but we have no way of knowing that without querying the
    // database. The query overhead is not worth it because set() should be called soon anyway.
    if (exp == null) return;
    if (exp.db != null && (this._refresh == null || exp.real < exp.db + this._refresh)) return;
    await this._write(sid, sess);
    exp.db = new Date(sess.cookie.expires).getTime();
  }
}

// express-session doesn't support Promise-based methods. This is where the callbackified versions
// used by express-session are defined.
for (const m of ['get', 'set', 'destroy', 'touch']) {
  SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);
}

module.exports = SessionStore;
