import * as Mocha from "mocha";
import * as Common from "mocha/lib/interfaces/common";
import * as Suite from "mocha/lib/suite";
import * as Test from "mocha/lib/test";

interface TestFunctions {
    it: {
        (name: string, fn: Function): void,
        only(name: string, fn: Function): void;
        skip(name: string, fn?: Function): void;
    };
    describe: {
        (name: string, fn: Function): void,
        only(name: string, fn: Function): void;
        skip(name: string, fn?: Function): void;
    };
    before: any;
    after: any;
    beforeEach: any;
    afterEach: any;
}

const globalTestFunctions: TestFunctions = {
    get describe() { return (global as any).describe; },
    get it() { return (global as any).it; },
    get before() { return (global as any).before; },
    get after() { return (global as any).after; },
    get beforeEach() { return (global as any).beforeEach; },
    get afterEach() { return (global as any).afterEach; },
};

// key => Symbol("mocha-typescript:" + key)
const nodeSymbol = (key) => "__mts_" + key;

const suiteSymbol = nodeSymbol("suite");
const testNameSymbol = nodeSymbol("test");
const parametersSymbol = nodeSymbol("parametersSymbol");
const nameForParametersSymbol = nodeSymbol("nameForParameters");
const slowSymbol = nodeSymbol("slow");
const timeoutSymbol = nodeSymbol("timout");
const retriesSymbol = nodeSymbol("retries");
const onlySymbol = nodeSymbol("only");
const pendingSymbol = nodeSymbol("pending");
const skipSymbol = nodeSymbol("skip");
const traitsSymbol = nodeSymbol("traits");
const isTraitSymbol = nodeSymbol("isTrait");
const contextSymbol = nodeSymbol("context");
const handled = nodeSymbol("handled");

type MochaDone = (error?: any) => any;

interface SuiteCtor {
    prototype: SuiteProto;
    before?: (done?: MochaDone) => void;
    after?: (done?: MochaDone) => void;
    new();
}
interface SuiteProto {
    before?: (done?: MochaDone) => void;
    after?: (done?: MochaDone) => void;
    [key: string]: any;
}

export type SuiteTrait = (this: Mocha.ISuiteCallbackContext, ctx: Mocha.ISuiteCallbackContext, ctor: SuiteCtor) => void;
export type TestTrait = (this: Mocha.ITestCallbackContext, ctx: Mocha.ITestCallbackContext, instance: SuiteProto, method: Function) => void;

const noname = (cb) => cb;

function applyDecorators(mocha: Mocha.IHookCallbackContext, ctorOrProto, method, instance) {
    const timeoutValue = method[timeoutSymbol];
    if (typeof timeoutValue === "number") {
        mocha.timeout(timeoutValue);
    }
    const slowValue = method[slowSymbol];
    if (mocha.slow && typeof slowValue === "number") {
        mocha.slow(slowValue);
    }
    const retriesValue = method[retriesSymbol];
    if (mocha.retries && typeof retriesValue === "number") {
        mocha.retries(retriesValue);
    }
    const contextProperty = ctorOrProto[contextSymbol];
    if (contextProperty) {
        instance[contextProperty] = mocha;
    }
}
function applyTestTraits(context: Mocha.ITestCallbackContext, instance: SuiteProto, method: Function) {
    const traits: TestTrait[] = method[traitsSymbol];
    if (traits) {
        traits.forEach((trait) => {
            trait.call(context, context, instance, method);
        });
    }
}
function applySuiteTraits(context: Mocha.ISuiteCallbackContext, target: SuiteCtor) {
    const traits: SuiteTrait[] = target[traitsSymbol];
    if (traits) {
        traits.forEach((trait) => {
            trait.call(context, context, target);
        });
    }
}

function suiteClassCallback(target: SuiteCtor, context: TestFunctions) {
    return function() {
        applySuiteTraits(this, target);
        applyDecorators(this, target, target, target);
        let instance;
        if (target.before) {
            if (target.before.length > 0) {
                context.before(function(done) {
                    applyDecorators(this, target, target.before, target);
                    return target.before(done);
                });
            } else {
                context.before(function() {
                    applyDecorators(this, target, target.before, target);
                    return target.before();
                });
            }
        }
        if (target.after) {
            if (target.after.length > 0) {
                context.after(function(done) {
                    applyDecorators(this, target, target.after, target);
                    return target.after(done);
                });
            } else {
                context.after(function() {
                    applyDecorators(this, target, target.after, target);
                    return target.after();
                });
            }
        }
        const prototype = target.prototype;
        let beforeEachFunction: (() => any) | ((done: Function) => any);
        if (prototype.before) {
            if (prototype.before.length > 0) {
                beforeEachFunction = noname(function(this: Mocha.IHookCallbackContext, done: Function) {
                    instance = getInstance(target);
                    applyDecorators(this, prototype, prototype.before, instance);
                    return prototype.before.call(instance, done);
                });
            } else {
                beforeEachFunction = noname(function(this: Mocha.IHookCallbackContext) {
                    instance = getInstance(target);
                    applyDecorators(this, prototype, prototype.before, instance);
                    return prototype.before.call(instance);
                });
            }
        } else {
            beforeEachFunction = noname(function(this: Mocha.IHookCallbackContext) {
                instance = getInstance(target);
            });
        }
        context.beforeEach(beforeEachFunction);

        let afterEachFunction: (() => any) | ((done: Function) => any);
        if (prototype.after) {
            if (prototype.after.length > 0) {
                afterEachFunction = noname(function(this: Mocha.IHookCallbackContext, done) {
                    try {
                        applyDecorators(this, prototype, prototype.after, instance);
                        return prototype.after.call(instance, done);
                    } finally {
                        instance = undefined;
                    }
                });
            } else {
                afterEachFunction = noname(function(this: Mocha.IHookCallbackContext) {
                    try {
                        applyDecorators(this, prototype, prototype.after, instance);
                        return prototype.after.call(instance);
                    } finally {
                        instance = undefined;
                    }
                });
            }
        } else {
            afterEachFunction = noname(function(this: Mocha.IHookCallbackContext) {
                instance = undefined;
            });
        }
        context.afterEach(afterEachFunction);

        function runTest(prototype: any, method: Function) {
            const testName = method[testNameSymbol] || (method as any).name;
            const shouldSkip = method[skipSymbol];
            const shouldOnly = method[onlySymbol];
            const shouldPending = method[pendingSymbol];
            const parameters = method[parametersSymbol] as TestParams[];

            if (testName || shouldOnly || shouldPending || shouldSkip) {
                if (shouldPending && !shouldSkip && !shouldOnly) {
                    context.it.skip(testName);
                } else if (parameters) {
                    const nameForParameters = method[nameForParametersSymbol];
                    parameters.forEach((parameterOptions, i) => {
                        const { mark, name, params } = parameterOptions;

                        let parametersTestName = `${testName}_${i}`;
                        if (name) {
                            parametersTestName = name;
                        } else if (nameForParameters) {
                            parametersTestName = nameForParameters(params);
                        }

                        const shouldSkipParam = shouldSkip || (mark === Mark.skip);
                        const shouldOnlyParam = shouldOnly || (mark === Mark.only);
                        const shouldPendingParam = shouldPending || (mark === Mark.pending);

                        if (shouldPendingParam && !shouldSkipParam && !shouldOnlyParam) {
                            context.it.skip(testName);
                        } else {
                            const testFunc = (shouldSkipParam && context.it.skip)
                                || (shouldOnlyParam && context.it.only)
                                || context.it;

                            applyTestFunc(testFunc, parametersTestName, method, [params], method.length <= 1);
                        }
                    });
                } else {
                    const testFunc = (shouldSkip && context.it.skip)
                        || (shouldOnly && context.it.only)
                        || context.it;

                    applyTestFunc(testFunc, testName, method, [], method.length === 0);
                }
            }
        }

        function applyTestFunc(testFunc: Function, testName: string,
                               method: Function, callArgs: any[],
                               sync: boolean = true) {
            if (sync) {
                testFunc(testName, noname(function(this: Mocha.ITestCallbackContext) {
                    applyDecorators(this, prototype, method, instance);
                    applyTestTraits(this, instance, method);
                    return method.call(instance, ...callArgs);
                }));
            } else {
                testFunc(testName, noname(function(this: Mocha.ITestCallbackContext, done) {
                    applyDecorators(this, prototype, method, instance);
                    applyTestTraits(this, instance, method);
                    return method.call(instance, ...callArgs, done);
                }));
            }
        }

        // collect all tests along the inheritance chain, allow overrides
        const collectedTests: { [key: string]: any[] } = {};
        let currentPrototype = prototype;
        while (currentPrototype !== Object.prototype) {

            Object.getOwnPropertyNames(currentPrototype).forEach((key) => {

                if (typeof prototype[key] === "function") {

                    const method = prototype[key];

                    if (method[testNameSymbol] && !collectedTests[key]) {

                        collectedTests[key] = [prototype, method];
                    }
                }
            });
            currentPrototype = (Object as any).getPrototypeOf(currentPrototype);
            if (currentPrototype !== Object.prototype && currentPrototype.constructor[suiteSymbol]) {
                throw new Error("deriving from other suites is bad practice and thus prohibited");
            }
        }

        // run all collected tests
        for (const key in collectedTests) {
            const value = collectedTests[key];
            runTest(value[0], value[1]);
        }
    };
}

function suiteOverload(overloads: {
    suite(name: string, fn: Function): any;
    suiteCtor(ctor: SuiteCtor): void;
    suiteDecorator(...traits: SuiteTrait[]): ClassDecorator;
    suiteDecoratorNamed(name: string, ...traits: SuiteTrait[]): ClassDecorator;
}) {
    return function() {
        if (arguments.length === 2 && typeof arguments[0] === "string" && typeof arguments[1] === "function" && !arguments[1][isTraitSymbol]) {
            return overloads.suite.apply(this, arguments);
        } else if (arguments.length === 1 && typeof arguments[0] === "function" && !arguments[0][isTraitSymbol]) {
            overloads.suiteCtor.apply(this, arguments);
        } else if (arguments.length >= 1 && typeof arguments[0] === "string") {
            return overloads.suiteDecoratorNamed.apply(this, arguments);
        } else {
            return overloads.suiteDecorator.apply(this, arguments);
        }
    };
}

function makeSuiteFunction(suiteFunc: (ctor?: SuiteCtor) => Function, context: TestFunctions) {
    return suiteOverload({
        suite(name: string, fn: Function): any {
            return suiteFunc()(name, fn);
        },
        suiteCtor(ctor: SuiteCtor): void {
            ctor[suiteSymbol] = true;
            suiteFunc(ctor)(ctor.name, suiteClassCallback(ctor, context));
        },
        suiteDecorator(...traits: SuiteTrait[]): ClassDecorator {
            return function <TFunction extends Function>(ctor: TFunction): void {
                ctor[suiteSymbol] = true;
                ctor[traitsSymbol] = traits;
                suiteFunc(ctor as any)(ctor.name, suiteClassCallback(ctor as any, context));
            };
        },
        suiteDecoratorNamed(name: string, ...traits: SuiteTrait[]): ClassDecorator {
            return function <TFunction extends Function>(ctor: TFunction): void {
                ctor[suiteSymbol] = true;
                ctor[traitsSymbol] = traits;
                suiteFunc(ctor as any)(name, suiteClassCallback(ctor as any, context));
            };
        },
    });
}

function suiteFuncCheckingDecorators(context: TestFunctions) {
    return function(ctor?: SuiteCtor) {
        if (ctor) {
            const shouldSkip = ctor[skipSymbol];
            const shouldOnly = ctor[onlySymbol];
            const shouldPending = ctor[pendingSymbol];
            return (shouldSkip && context.describe.skip)
                || (shouldOnly && context.describe.only)
                || (shouldPending && context.describe.skip)
                || context.describe;
        } else {
            return context.describe;
        }
    };
}

function makeSuiteObject(context: TestFunctions): Suite {
    return Object.assign(makeSuiteFunction(suiteFuncCheckingDecorators(context), context), {
        skip: makeSuiteFunction(() => context.describe.skip, context),
        only: makeSuiteFunction(() => context.describe.only, context),
        pending: makeSuiteFunction(() => context.describe.skip, context),
    });
}
export const suite = makeSuiteObject(globalTestFunctions);

const enum Mark { test, skip, only, pending }

interface TestParams {
    mark: Mark;
    name?: string;
    params: any;
}

function makeParamsFunction(mark: Mark) {
    return (params: any, name?: string) => {
        return (target: Object, propertyKey: string) => {
            target[propertyKey][testNameSymbol] = propertyKey ? propertyKey.toString() : "";
            target[propertyKey][parametersSymbol] = target[propertyKey][parametersSymbol] || [];
            target[propertyKey][parametersSymbol].push({ mark, name, params } as TestParams);
        };
    };
}

function makeParamsNameFunction() {
    return (nameForParameters: (parameters: any) => string) => {
        return (target: Object, propertyKey: string) => {
            target[propertyKey][nameForParametersSymbol] = nameForParameters;
        };
    };
}

function makeParamsObject(context: TestFunctions) {
    return Object.assign(makeParamsFunction(Mark.test), {
        skip: makeParamsFunction(Mark.skip),
        only: makeParamsFunction(Mark.only),
        pending: makeParamsFunction(Mark.pending),
        naming: makeParamsNameFunction(),
    });
}
export const params = makeParamsObject(globalTestFunctions);

function testOverload(overloads: {
    test(name: string, fn: Function);
    testProperty(target: Object, propertyKey: string | symbol, descriptor?: PropertyDescriptor): void;
    testDecorator(...traits: TestTrait[]): PropertyDecorator & MethodDecorator;
    testDecoratorNamed(name: string, ...traits: TestTrait[]): PropertyDecorator & MethodDecorator;
}) {
    return function() {
        if (arguments.length === 2 && typeof arguments[0] === "string" && typeof arguments[1] === "function" && !arguments[1][isTraitSymbol]) {
            return overloads.test.apply(this, arguments);
        } else if (arguments.length >= 2 && typeof arguments[0] !== "string" && typeof arguments[0] !== "function") {
            overloads.testProperty.apply(this, arguments);
        } else if (arguments.length >= 1 && typeof arguments[0] === "string") {
            return overloads.testDecoratorNamed.apply(this, arguments);
        } else {
            return overloads.testDecorator.apply(this, arguments);
        }
    };
}

function makeTestFunction(testFunc: () => Function, mark: null | string | symbol) {
    return testOverload({
        test(name: string, fn: Function) {
            testFunc()(name, fn);
        },
        testProperty(target: Object, propertyKey: string | symbol, descriptor?: PropertyDescriptor): void {
            target[propertyKey][testNameSymbol] = propertyKey ? propertyKey.toString() : "";
            if (mark) {
                target[propertyKey][mark] = true;
            }
        },
        testDecorator(...traits: TestTrait[]): PropertyDecorator & MethodDecorator {
            return function(target: Object, propertyKey: string | symbol, descriptor?: PropertyDescriptor): void {
                target[propertyKey][testNameSymbol] = propertyKey ? propertyKey.toString() : "";
                target[propertyKey][traitsSymbol] = traits;
                if (mark) {
                    target[propertyKey][mark] = true;
                }
            };
        },
        testDecoratorNamed(name: string, ...traits: TestTrait[]): PropertyDecorator & MethodDecorator {
            return function(target: Object, propertyKey: string | symbol, descriptor?: PropertyDescriptor): void {
                target[propertyKey][testNameSymbol] = name;
                target[propertyKey][traitsSymbol] = traits;
                if (mark) {
                    target[propertyKey][mark] = true;
                }
            };
        },
    });
}
function makeTestObject(context: TestFunctions): Test {
    return Object.assign(makeTestFunction(() => context.it, null), {
        skip: makeTestFunction(() => context.it.skip, skipSymbol),
        only: makeTestFunction(() => context.it.only, onlySymbol),
        pending: makeTestFunction(() => context.it.skip, pendingSymbol),
    });
}
export const test = makeTestObject(globalTestFunctions);

export function trait<T extends SuiteTrait | TestTrait>(arg: T): T {
    arg[isTraitSymbol] = true;
    return arg;
}

/**
 * Set a test method execution time that is considered slow.
 * @param time The time in miliseconds.
 */
export function slow(time: number): PropertyDecorator & ClassDecorator & SuiteTrait & TestTrait {
    return trait(function() {
        if (arguments.length === 1) {
            const target = arguments[0];
            target[slowSymbol] = time;
        } else if (arguments.length === 2 && typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
            const target = arguments[0];
            const property = arguments[1];
            target[property][slowSymbol] = time;
        } else if (arguments.length === 2) {
            const context: Mocha.ISuiteCallbackContext = arguments[0];
            const ctor = arguments[1];
            context.slow(time);
        } else if (arguments.length === 3) {
            if (typeof arguments[2] === "function") {
                const context: Mocha.ITestCallbackContext = arguments[0];
                const instance = arguments[1];
                const method = arguments[2];
                context.slow(time);
            } else if (typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
                const proto: Mocha.ITestCallbackContext = arguments[0];
                const prop = arguments[1];
                const descriptor = arguments[2];
                proto[prop][slowSymbol] = time;
            }
        }
    });
}

/**
 * Set a test method or suite timeout time.
 * @param time The time in miliseconds.
 */
export function timeout(time: number): MethodDecorator & PropertyDecorator & ClassDecorator & SuiteTrait & TestTrait {
    return trait(function() {
        if (arguments.length === 1) {
            const target = arguments[0];
            target[timeoutSymbol] = time;
        } else if (arguments.length === 2 && typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
            const target = arguments[0];
            const property = arguments[1];
            target[property][timeoutSymbol] = time;
        } else if (arguments.length === 2) {
            const context: Mocha.ISuiteCallbackContext = arguments[0];
            const ctor = arguments[1];
            context.timeout(time);
        } else if (arguments.length === 3) {
            if (typeof arguments[2] === "function") {
                const context: Mocha.ITestCallbackContext = arguments[0];
                const instance = arguments[1];
                const method = arguments[2];
                context.timeout(time);
            } else if (typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
                const proto: Mocha.ITestCallbackContext = arguments[0];
                const prop = arguments[1];
                const descriptor = arguments[2];
                proto[prop][timeoutSymbol] = time;
            }
        }
    });
}

/**
 * Set a test method or site retries count.
 * @param count The number of retries to attempt when running the test.
 */
export function retries(count: number): MethodDecorator & PropertyDecorator & ClassDecorator & SuiteTrait & TestTrait {
    return trait(function() {
        if (arguments.length === 1) {
            const target = arguments[0];
            target[retriesSymbol] = count;
        } else if (arguments.length === 2 && typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
            const target = arguments[0];
            const property = arguments[1];
            target[property][retriesSymbol] = count;
        } else if (arguments.length === 2) {
            const context: Mocha.ISuiteCallbackContext = arguments[0];
            const ctor = arguments[1];
            context.retries(count);
        } else if (arguments.length === 3) {
            if (typeof arguments[2] === "function") {
                const context: Mocha.ITestCallbackContext = arguments[0];
                const instance = arguments[1];
                const method = arguments[2];
                context.retries(count);
            } else if (typeof arguments[1] === "string" || typeof arguments[1] === "symbol") {
                const proto: Mocha.ITestCallbackContext = arguments[0];
                const prop = arguments[1];
                const descriptor = arguments[2];
                proto[prop][retriesSymbol] = count;
            }
        }
    });
}

export const skipOnError: SuiteTrait = trait(function(ctx, ctor) {
    ctx.beforeEach(function() {
        if (ctor.__skip_all) {
            this.skip();
        }
    });
    ctx.afterEach(function() {
        if (this.currentTest.state === "failed") {
            ctor.__skip_all = true;
        }
    });
});

/**
 * Mart a test or suite as pending.
 *  - Used as `@suite @pending class` is `describe.skip("name", ...);`.
 *  - Used as `@test @pending method` is `it("name");`
 */
export function pending<TFunction extends Function>(target: Object | TFunction, propertyKey?: string | symbol): void {
    if (arguments.length === 1) {
        target[pendingSymbol] = true;
    } else {
        target[propertyKey][pendingSymbol] = true;
    }
}

/**
 * Mark a test or suite as the only one to execute.
 *  - Used as `@suite @only class` is `describe.only("name", ...)`.
 *  - Used as `@test @only method` is `it.only("name", ...)`.
 */
export function only<TFunction extends Function>(target: Object, propertyKey?: string | symbol): void {
    if (arguments.length === 1) {
        target[onlySymbol] = true;
    } else {
        target[propertyKey][onlySymbol] = true;
    }
}

/**
 * Mark a test or suite to skip.
 *  - Used as `@suite @skip class` is `describe.skip("name", ...);`.
 *  - Used as `@test @skip method` is `it.skip("name")`.
 */
export function skip<TFunction extends Function>(target: Object | TFunction, propertyKey?: string | symbol): void {
    if (arguments.length === 1) {
        target[onlySymbol] = true;
    } else {
        target[propertyKey][skipSymbol] = true;
    }
}

/**
 * Mark a method as test. Use the method name as test name.
 */
export function context(target: Object, propertyKey: string | symbol): void {
    target[contextSymbol] = propertyKey;
}

/**
 * Rip-off the TDD and BDD at: mocha/lib/interfaces/tdd.js and mocha/lib/interfaces/bdd.js
 * Augmented the suite and test for the mocha-typescript decorators.
 */
function tsdd(suite) {
    const suites = [suite];

    suite.on("pre-require", function(context, file, mocha) {
        const common = Common(suites, context, mocha);

        context.before = common.before;
        context.after = common.after;
        context.beforeEach = common.beforeEach;
        context.afterEach = common.afterEach;
        context.run = mocha.options.delay && common.runWithSuite(suite);

        // Copy of bdd
        context.describe = context.context = function(title, fn) {
            return common.suite.create({
                title,
                file,
                fn,
            });
        };
        context.xdescribe = context.xcontext = context.describe.skip = function(title, fn) {
            return common.suite.skip({
                title,
                file,
                fn,
            });
        };
        context.describe.only = function(title, fn) {
            return common.suite.only({
                title,
                file,
                fn,
            });
        };
        context.it = context.specify = function(title, fn) {
            const suite = suites[0];
            if (suite.isPending()) {
                fn = null;
            }
            const test = new Test(title, fn);
            test.file = file;
            suite.addTest(test);
            return test;
        };
        context.it.only = function(title, fn) {
            return common.test.only(mocha, context.it(title, fn));
        };
        context.xit = context.xspecify = context.it.skip = function(title) {
            context.it(title);
        };
        context.it.retries = function(n) {
            context.retries(n);
        };

        context.suite = makeSuiteObject(context);
        context.params = makeParamsObject(context);
        context.test = makeTestObject(context);

        context.test.retries = common.test.retries;

        context.timeout = timeout;
        context.slow = slow;
        context.retries = retries;
        context.skipOnError = skipOnError;
    });
}

interface TestClass<T> {
    new(...args: any[]): T;
    prototype: T;
}

interface DependencyInjectionSystem {
    handles<T>(cls: TestClass<T>): boolean;
    create<T>(cls: TestClass<T>): typeof cls.prototype;
}

const defaultDependencyInjectionSystem: DependencyInjectionSystem = {
    handles() { return true; },
    create<T>(cls: TestClass<T>) {
        return new cls();
    },
};

const dependencyInjectionSystems: DependencyInjectionSystem[] = [defaultDependencyInjectionSystem];

function getInstance<T>(testClass: TestClass<T>) {
    const di = dependencyInjectionSystems.find((di) => di.handles(testClass));
    return di.create(testClass);
}

/**
 * Register a dependency injection system.
 */
export function registerDI(instantiator: DependencyInjectionSystem) {
    // Maybe check if it is not already added?
    if (dependencyInjectionSystems.some((di) => di === instantiator)) { return false; }
    dependencyInjectionSystems.unshift(instantiator);
    return true;
}

module.exports = Object.assign(tsdd, exports);
(Mocha as any).interfaces["mocha-typescript"] = tsdd;