"use strict";

const arrayProto = require("@sinonjs/commons").prototypes.array;
const extend = require("./util/core/extend");
const functionName = require("@sinonjs/commons").functionName;
const nextTick = require("./util/core/next-tick");
const valueToString = require("@sinonjs/commons").valueToString;
const exportAsyncBehaviors = require("./util/core/export-async-behaviors");

const concat = arrayProto.concat;
const join = arrayProto.join;
const reverse = arrayProto.reverse;
const slice = arrayProto.slice;

const useLeftMostCallback = -1;
const useRightMostCallback = -2;

function getCallback(behavior, args) {
    const callArgAt = behavior.callArgAt;

    if (callArgAt >= 0) {
        return args[callArgAt];
    }

    let argumentList;

    if (callArgAt === useLeftMostCallback) {
        argumentList = args;
    }

    if (callArgAt === useRightMostCallback) {
        argumentList = reverse(slice(args));
    }

    const callArgProp = behavior.callArgProp;

    for (let i = 0, l = argumentList.length; i < l; ++i) {
        if (!callArgProp && typeof argumentList[i] === "function") {
            return argumentList[i];
        }

        if (
            callArgProp &&
            argumentList[i] &&
            typeof argumentList[i][callArgProp] === "function"
        ) {
            return argumentList[i][callArgProp];
        }
    }

    return null;
}

function getCallbackError(behavior, func, args) {
    if (behavior.callArgAt < 0) {
        let msg;

        if (behavior.callArgProp) {
            msg = `${functionName(
                behavior.stub
            )} expected to yield to '${valueToString(
                behavior.callArgProp
            )}', but no object with such a property was passed.`;
        } else {
            msg = `${functionName(
                behavior.stub
            )} expected to yield, but no callback was passed.`;
        }

        if (args.length > 0) {
            msg += ` Received [${join(args, ", ")}]`;
        }

        return msg;
    }

    return `argument at index ${behavior.callArgAt} is not a function: ${func}`;
}

function ensureArgs(name, behavior, args) {
    // map function name to internal property
    //   callsArg => callArgAt
    const property = name.replace(/sArg/, "ArgAt");
    const index = behavior[property];

    if (index >= args.length) {
        throw new TypeError(
            `${name} failed: ${index + 1} arguments required but only ${
                args.length
            } present`
        );
    }
}

function callCallback(behavior, args) {
    if (typeof behavior.callArgAt === "number") {
        ensureArgs("callsArg", behavior, args);
        const func = getCallback(behavior, args);

        if (typeof func !== "function") {
            throw new TypeError(getCallbackError(behavior, func, args));
        }

        if (behavior.callbackAsync) {
            nextTick(function () {
                func.apply(
                    behavior.callbackContext,
                    behavior.callbackArguments
                );
            });
        } else {
            return func.apply(
                behavior.callbackContext,
                behavior.callbackArguments
            );
        }
    }

    return undefined;
}

const proto = {
    create: function create(stub) {
        const behavior = extend({}, proto);
        delete behavior.create;
        delete behavior.addBehavior;
        delete behavior.createBehavior;
        behavior.stub = stub;

        if (stub.defaultBehavior && stub.defaultBehavior.promiseLibrary) {
            behavior.promiseLibrary = stub.defaultBehavior.promiseLibrary;
        }

        return behavior;
    },

    isPresent: function isPresent() {
        return (
            typeof this.callArgAt === "number" ||
            this.exception ||
            this.exceptionCreator ||
            typeof this.returnArgAt === "number" ||
            this.returnThis ||
            typeof this.resolveArgAt === "number" ||
            this.resolveThis ||
            typeof this.throwArgAt === "number" ||
            this.fakeFn ||
            this.returnValueDefined
        );
    },

    /*eslint complexity: ["error", 20]*/
    invoke: function invoke(context, args) {
        /*
         * callCallback (conditionally) calls ensureArgs
         *
         * Note: callCallback intentionally happens before
         * everything else and cannot be moved lower
         */
        const returnValue = callCallback(this, args);

        if (this.exception) {
            throw this.exception;
        } else if (this.exceptionCreator) {
            this.exception = this.exceptionCreator();
            this.exceptionCreator = undefined;
            throw this.exception;
        } else if (typeof this.returnArgAt === "number") {
            ensureArgs("returnsArg", this, args);
            return args[this.returnArgAt];
        } else if (this.returnThis) {
            return context;
        } else if (typeof this.throwArgAt === "number") {
            ensureArgs("throwsArg", this, args);
            throw args[this.throwArgAt];
        } else if (this.fakeFn) {
            return this.fakeFn.apply(context, args);
        } else if (typeof this.resolveArgAt === "number") {
            ensureArgs("resolvesArg", this, args);
            return (this.promiseLibrary || Promise).resolve(
                args[this.resolveArgAt]
            );
        } else if (this.resolveThis) {
            return (this.promiseLibrary || Promise).resolve(context);
        } else if (this.resolve) {
            return (this.promiseLibrary || Promise).resolve(this.returnValue);
        } else if (this.reject) {
            return (this.promiseLibrary || Promise).reject(this.returnValue);
        } else if (this.callsThrough) {
            const wrappedMethod = this.effectiveWrappedMethod();

            return wrappedMethod.apply(context, args);
        } else if (this.callsThroughWithNew) {
            // Get the original method (assumed to be a constructor in this case)
            const WrappedClass = this.effectiveWrappedMethod();
            // Turn the arguments object into a normal array
            const argsArray = slice(args);
            // Call the constructor
            const F = WrappedClass.bind.apply(
                WrappedClass,
                concat([null], argsArray)
            );
            return new F();
        } else if (typeof this.returnValue !== "undefined") {
            return this.returnValue;
        } else if (typeof this.callArgAt === "number") {
            return returnValue;
        }

        return this.returnValue;
    },

    effectiveWrappedMethod: function effectiveWrappedMethod() {
        for (let stubb = this.stub; stubb; stubb = stubb.parent) {
            if (stubb.wrappedMethod) {
                return stubb.wrappedMethod;
            }
        }
        throw new Error("Unable to find wrapped method");
    },

    onCall: function onCall(index) {
        return this.stub.onCall(index);
    },

    onFirstCall: function onFirstCall() {
        return this.stub.onFirstCall();
    },

    onSecondCall: function onSecondCall() {
        return this.stub.onSecondCall();
    },

    onThirdCall: function onThirdCall() {
        return this.stub.onThirdCall();
    },

    withArgs: function withArgs(/* arguments */) {
        throw new Error(
            'Defining a stub by invoking "stub.onCall(...).withArgs(...)" ' +
                'is not supported. Use "stub.withArgs(...).onCall(...)" ' +
                "to define sequential behavior for calls with certain arguments."
        );
    },
};

function createBehavior(behaviorMethod) {
    return function () {
        this.defaultBehavior = this.defaultBehavior || proto.create(this);
        this.defaultBehavior[behaviorMethod].apply(
            this.defaultBehavior,
            arguments
        );
        return this;
    };
}

function addBehavior(stub, name, fn) {
    proto[name] = function () {
        fn.apply(this, concat([this], slice(arguments)));
        return this.stub || this;
    };

    stub[name] = createBehavior(name);
}

proto.addBehavior = addBehavior;
proto.createBehavior = createBehavior;

const asyncBehaviors = exportAsyncBehaviors(proto);

module.exports = extend.nonEnum({}, proto, asyncBehaviors);
