'use strict';
var inspect = require('util').inspect;
var isGeneratorFn = require('is-generator-fn');
var maxTimeout = require('max-timeout');
var Promise = require('bluebird');
var fnName = require('fn-name');
var co = require('co-with-promise');
var observableToPromise = require('observable-to-promise');
var isPromise = require('is-promise');
var isObservable = require('is-observable');
var plur = require('plur');
var assert = require('./assert');
var enhanceAssert = require('./enhance-assert');
var globals = require('./globals');
var throwsHelper = require('./throws-helper');

function Test(title, fn, contextRef, report) {
	if (!(this instanceof Test)) {
		throw new TypeError('Class constructor Test cannot be invoked without \'new\'');
	}

	if (typeof title === 'function') {
		contextRef = fn;
		fn = title;
		title = null;
	}

	assert.is(typeof fn, 'function', 'you must provide a callback');

	this.title = title || fnName(fn) || '[anonymous]';
	this.fn = isGeneratorFn(fn) ? co.wrap(fn) : fn;
	this.assertions = [];
	this.planCount = null;
	this.duration = null;
	this.assertError = undefined;
	this.sync = true;
	this.contextRef = contextRef;
	this.report = report;
	this.threwSync = false;

	// TODO(jamestalmage): make this an optional constructor arg instead of having Runner set it after the fact.
	// metadata should just always exist, otherwise it requires a bunch of ugly checks all over the place.
	this.metadata = {};

	// store the time point before test execution
	// to calculate the total time spent in test
	this._timeStart = null;

	// workaround for Babel giving anonymous functions a name
	if (this.title === 'callee$0$0') {
		this.title = '[anonymous]';
	}
}

module.exports = Test;

Object.defineProperty(Test.prototype, 'assertCount', {
	enumerable: true,
	get: function () {
		return this.assertions.length;
	}
});

Test.prototype._assert = function (promise) {
	if (isPromise(promise)) {
		this.sync = false;
	}

	this.assertions.push(promise);
};

Test.prototype._setAssertError = function (err) {
	throwsHelper(err);
	if (this.assertError !== undefined) {
		return;
	}

	this.assertError = err;
};

Test.prototype.plan = function (count, planStack) {
	if (typeof count !== 'number') {
		throw new TypeError('Expected a number');
	}

	this.planCount = count;

	// in case the `planCount` doesn't match `assertCount,
	// we need the stack of this function to throw with a useful stack
	this.planStack = planStack;
};

Test.prototype._run = function () {
	var ret;

	try {
		ret = this.fn(this._publicApi());
	} catch (err) {
		this.threwSync = true;
		if (err instanceof Error) {
			this._setAssertError(err);
		} else {
			this._setAssertError(new assert.AssertionError({
				actual: err,
				message: 'Non-error thrown with value: ' + inspect(err, {depth: null}),
				operator: 'catch'
			}));
		}
	}

	return ret;
};

Test.prototype.promise = function () {
	var self = this;

	if (!this._promise) {
		this._promise = {};

		this._promise.promise = new Promise(function (resolve, reject) { // eslint-disable-line
			self._promise.resolve = resolve;
			self._promise.reject = reject;
		}).tap(function (result) {
			if (self.report) {
				self.report(result);
			}
		});
	}

	return this._promise;
};

Test.prototype.run = function () {
	if (this.metadata.callback) {
		this.sync = false;
	}

	var self = this;

	this._timeStart = globals.now();

	// wait until all assertions are complete
	this._timeout = globals.setTimeout(function () {}, maxTimeout);

	var ret = this._run();

	var asyncType = 'promises';

	if (isObservable(ret)) {
		asyncType = 'observables';
		ret = observableToPromise(ret);
	}

	if (isPromise(ret)) {
		this.sync = false;

		if (this.metadata.callback) {
			self._setAssertError(new Error('Do not return ' + asyncType + ' from tests declared via `test.cb(...)`, if you want to return a promise simply declare the test via `test(...)`'));
		}

		ret.then(
			function () {
				self.exit();
			},
			function (err) {
				if (!(err instanceof Error)) {
					err = new assert.AssertionError({
						actual: err,
						message: 'Promise rejected with: ' + inspect(err, {depth: null}),
						operator: 'promise'
					});
				}

				self._setAssertError(err);
				self.exit();
			});

		return this.promise().promise;
	}

	if (this.metadata.callback && !this.threwSync) {
		return this.promise().promise;
	}

	return this.exit();
};

Test.prototype._result = function () {
	var reason = this.assertError;
	var passed = reason === undefined;
	if (this.metadata.failing) {
		passed = !passed;
		if (passed) {
			reason = undefined;
		} else {
			reason = new Error('Test was expected to fail, but succeeded, you should stop marking the test as failing');
		}
	}
	return {passed: passed, result: this, reason: reason};
};

Object.defineProperty(Test.prototype, 'end', {
	get: function () {
		if (this.metadata.callback) {
			return this._end.bind(this);
		}

		throw new Error('t.end is not supported in this context. To use t.end as a callback, you must use "callback mode" via `test.cb(testName, fn)`');
	}
});

Test.prototype._end = function (err) {
	if (err) {
		if (!(err instanceof Error)) {
			err = new assert.AssertionError({
				actual: err,
				message: 'Callback called with an error: ' + inspect(err, {depth: null}),
				operator: 'callback'
			});
		}
		this._setAssertError(err);

		this.exit();
		return;
	}

	if (this.endCalled) {
		this._setAssertError(new Error('.end() called more than once'));
		return;
	}

	this.endCalled = true;
	this.exit();
};

Test.prototype._checkPlanCount = function () {
	if (this.assertError === undefined && this.planCount !== null && this.planCount !== this.assertions.length) {
		this._setAssertError(new assert.AssertionError({
			actual: this.assertions.length,
			expected: this.planCount,
			message: 'Planned for ' + this.planCount + plur(' assertion', this.planCount) + ', but got ' + this.assertions.length + '.',
			operator: 'plan'
		}));

		this.assertError.stack = this.planStack;
	}
};

Test.prototype.exit = function () {
	var self = this;

	this._checkPlanCount();

	if (this.sync || this.threwSync) {
		self.duration = globals.now() - self._timeStart;
		globals.clearTimeout(self._timeout);

		var result = this._result();

		if (this.report) {
			this.report(result);
		}

		return result;
	}

	Promise.all(this.assertions)
		.catch(function (err) {
			self._setAssertError(err);
		})
		.finally(function () {
			// calculate total time spent in test
			self.duration = globals.now() - self._timeStart;

			// stop infinite timer
			globals.clearTimeout(self._timeout);

			self._checkPlanCount();

			self.promise().resolve(self._result());
		});

	return self.promise().promise;
};

Test.prototype._publicApi = function () {
	return new PublicApi(this);
};

function PublicApi(test) {
	this._test = test;
	this.skip = new SkipApi(test);
}

function onAssertionEvent(event) {
	if (event.assertionThrew) {
		event.error.powerAssertContext = event.powerAssertContext;
		event.error.originalMessage = event.originalMessage;
		this._test._setAssertError(event.error);
		this._test._assert(null);
		return null;
	}

	var ret = event.returnValue;

	if (isObservable(ret)) {
		ret = observableToPromise(ret);
	}

	if (isPromise(ret)) {
		var promise = ret.then(null, function (err) {
			err.originalMessage = event.originalMessage;
			throw err;
		});

		this._test._assert(promise);

		return promise;
	}

	this._test._assert(null);

	return ret;
}

PublicApi.prototype = enhanceAssert({
	assert: assert,
	onSuccess: onAssertionEvent,
	onError: onAssertionEvent
});

PublicApi.prototype.plan = function plan(ct) {
	var limitBefore = Error.stackTraceLimit;
	Error.stackTraceLimit = 1;
	var obj = {};
	Error.captureStackTrace(obj, plan);
	Error.stackTraceLimit = limitBefore;
	this._test.plan(ct, obj.stack);
};

// Getters
[
	'assertCount',
	'title',
	'end'
]
	.forEach(function (name) {
		Object.defineProperty(PublicApi.prototype, name, {
			enumerable: false,
			get: function () {
				return this._test[name];
			}
		});
	});

// Get / Set
Object.defineProperty(PublicApi.prototype, 'context', {
	enumerable: true,
	get: function () {
		var contextRef = this._test.contextRef;
		return contextRef && contextRef.context;
	},
	set: function (context) {
		var contextRef = this._test.contextRef;

		if (!contextRef) {
			this._test._setAssertError(new Error('t.context is not available in ' + this._test.metadata.type + ' tests'));
			return;
		}

		contextRef.context = context;
	}
});

function skipFn() {
	return this._test._assert(null);
}

function SkipApi(test) {
	this._test = test;
}

Object.keys(assert).forEach(function (el) {
	SkipApi.prototype[el] = skipFn;
});
