'use strict';
// Flags: --expose-gc

const common = require('../common');
const assert = require('assert');
const async_hooks = require('async_hooks');
const util = require('util');
const print = process._rawDebug;

if (typeof global.gc === 'function') {
  (function exity(cntr) {
    process.once('beforeExit', () => {
      global.gc();
      if (cntr < 4) setImmediate(() => exity(cntr + 1));
    });
  })(0);
}

function noop() {}

class ActivityCollector {
  constructor(start, {
    allowNoInit = false,
    oninit,
    onbefore,
    onafter,
    ondestroy,
    onpromiseResolve,
    logid = null,
    logtype = null
  } = {}) {
    this._start = start;
    this._allowNoInit = allowNoInit;
    this._activities = new Map();
    this._logid = logid;
    this._logtype = logtype;

    // Register event handlers if provided
    this.oninit = typeof oninit === 'function' ? oninit : noop;
    this.onbefore = typeof onbefore === 'function' ? onbefore : noop;
    this.onafter = typeof onafter === 'function' ? onafter : noop;
    this.ondestroy = typeof ondestroy === 'function' ? ondestroy : noop;
    this.onpromiseResolve = typeof onpromiseResolve === 'function' ?
      onpromiseResolve : noop;

    // Create the hook with which we'll collect activity data
    this._asyncHook = async_hooks.createHook({
      init: this._init.bind(this),
      before: this._before.bind(this),
      after: this._after.bind(this),
      destroy: this._destroy.bind(this),
      promiseResolve: this._promiseResolve.bind(this)
    });
  }

  enable() {
    this._asyncHook.enable();
  }

  disable() {
    this._asyncHook.disable();
  }

  sanityCheck(types) {
    if (types != null && !Array.isArray(types)) types = [ types ];

    function activityString(a) {
      return util.inspect(a, false, 5, true);
    }

    const violations = [];
    let tempActivityString;

    function v(msg) { violations.push(msg); }
    for (const a of this._activities.values()) {
      tempActivityString = activityString(a);
      if (types != null && !types.includes(a.type)) continue;

      if (a.init && a.init.length > 1) {
        v(`Activity inited twice\n${tempActivityString}` +
          '\nExpected "init" to be called at most once');
      }
      if (a.destroy && a.destroy.length > 1) {
        v(`Activity destroyed twice\n${tempActivityString}` +
          '\nExpected "destroy" to be called at most once');
      }
      if (a.before && a.after) {
        if (a.before.length < a.after.length) {
          v('Activity called "after" without calling "before"\n' +
            `${tempActivityString}` +
            '\nExpected no "after" call without a "before"');
        }
        if (a.before.some((x, idx) => x > a.after[idx])) {
          v('Activity had an instance where "after" ' +
            'was invoked before "before"\n' +
            `${tempActivityString}` +
            '\nExpected "after" to be called after "before"');
        }
      }
      if (a.before && a.destroy) {
        if (a.before.some((x, idx) => x > a.destroy[idx])) {
          v('Activity had an instance where "destroy" ' +
            'was invoked before "before"\n' +
            `${tempActivityString}` +
            '\nExpected "destroy" to be called after "before"');
        }
      }
      if (a.after && a.destroy) {
        if (a.after.some((x, idx) => x > a.destroy[idx])) {
          v('Activity had an instance where "destroy" ' +
            'was invoked before "after"\n' +
            `${tempActivityString}` +
            '\nExpected "destroy" to be called after "after"');
        }
      }
      if (!a.handleIsObject) {
        v(`No resource object\n${tempActivityString}` +
          '\nExpected "init" to be called with a resource object');
      }
    }
    if (violations.length) {
      console.error(violations.join('\n\n') + '\n');
      assert.fail(`${violations.length} failed sanity checks`);
    }
  }

  inspect(opts = {}) {
    if (typeof opts === 'string') opts = { types: opts };
    const { types = null, depth = 5, stage = null } = opts;
    const activities = types == null ?
      Array.from(this._activities.values()) :
      this.activitiesOfTypes(types);

    if (stage != null) console.log(`\n${stage}`);
    console.log(util.inspect(activities, false, depth, true));
  }

  activitiesOfTypes(types) {
    if (!Array.isArray(types)) types = [ types ];
    return this.activities.filter((x) => types.includes(x.type));
  }

  get activities() {
    return Array.from(this._activities.values());
  }

  _stamp(h, hook) {
    if (h == null) return;
    if (h[hook] == null) h[hook] = [];
    const time = process.hrtime(this._start);
    h[hook].push((time[0] * 1e9) + time[1]);
  }

  _getActivity(uid, hook) {
    const h = this._activities.get(uid);
    if (!h) {
      // If we allowed handles without init we ignore any further life time
      // events this makes sense for a few tests in which we enable some hooks
      // later
      if (this._allowNoInit) {
        const stub = { uid, type: 'Unknown', handleIsObject: true, handle: {} };
        this._activities.set(uid, stub);
        return stub;
      } else if (!common.isMainThread) {
        // Worker threads start main script execution inside of an AsyncWrap
        // callback, so we don't yield errors for these.
        return null;
      }
      const err = new Error(`Found a handle whose ${hook}` +
                            ' hook was invoked but not its init hook');
      // Don't throw if we see invocations due to an assertion in a test
      // failing since we want to list the assertion failure instead
      if (/process\._fatalException/.test(err.stack)) return null;
      throw err;
    }
    return h;
  }

  _init(uid, type, triggerAsyncId, handle) {
    const activity = {
      uid,
      type,
      triggerAsyncId,
      // In some cases (e.g. Timeout) the handle is a function, thus the usual
      // `typeof handle === 'object' && handle !== null` check can't be used.
      handleIsObject: handle instanceof Object,
      handle
    };
    this._stamp(activity, 'init');
    this._activities.set(uid, activity);
    this._maybeLog(uid, type, 'init');
    this.oninit(uid, type, triggerAsyncId, handle);
  }

  _before(uid) {
    const h = this._getActivity(uid, 'before');
    this._stamp(h, 'before');
    this._maybeLog(uid, h && h.type, 'before');
    this.onbefore(uid);
  }

  _after(uid) {
    const h = this._getActivity(uid, 'after');
    this._stamp(h, 'after');
    this._maybeLog(uid, h && h.type, 'after');
    this.onafter(uid);
  }

  _destroy(uid) {
    const h = this._getActivity(uid, 'destroy');
    this._stamp(h, 'destroy');
    this._maybeLog(uid, h && h.type, 'destroy');
    this.ondestroy(uid);
  }

  _promiseResolve(uid) {
    const h = this._getActivity(uid, 'promiseResolve');
    this._stamp(h, 'promiseResolve');
    this._maybeLog(uid, h && h.type, 'promiseResolve');
    this.onpromiseResolve(uid);
  }

  _maybeLog(uid, type, name) {
    if (this._logid &&
      (type == null || this._logtype == null || this._logtype === type)) {
      print(`${this._logid}.${name}.uid-${uid}`);
    }
  }
}

exports = module.exports = function initHooks({
  oninit,
  onbefore,
  onafter,
  ondestroy,
  onpromiseResolve,
  allowNoInit,
  logid,
  logtype
} = {}) {
  return new ActivityCollector(process.hrtime(), {
    oninit,
    onbefore,
    onafter,
    ondestroy,
    onpromiseResolve,
    allowNoInit,
    logid,
    logtype
  });
};