"use strict";

const arrayProto = require("@sinonjs/commons").prototypes.array;
const createProxy = require("./proxy");
const extend = require("./util/core/extend");
const functionName = require("@sinonjs/commons").functionName;
const getPropertyDescriptor = require("./util/core/get-property-descriptor");
const deepEqual = require("@sinonjs/samsam").deepEqual;
const isEsModule = require("./util/core/is-es-module");
const proxyCallUtil = require("./proxy-call-util");
const walkObject = require("./util/core/walk-object");
const wrapMethod = require("./util/core/wrap-method");
const valueToString = require("@sinonjs/commons").valueToString;

/* cache references to library methods so that they also can be stubbed without problems */
const forEach = arrayProto.forEach;
const pop = arrayProto.pop;
const push = arrayProto.push;
const slice = arrayProto.slice;
const filter = Array.prototype.filter;

let uuid = 0;

function matches(fake, args, strict) {
    const margs = fake.matchingArguments;
    if (
        margs.length <= args.length &&
        deepEqual(slice(args, 0, margs.length), margs)
    ) {
        return !strict || margs.length === args.length;
    }
    return false;
}

// Public API
const spyApi = {
    withArgs: function () {
        const args = slice(arguments);
        const matching = pop(this.matchingFakes(args, true));
        if (matching) {
            return matching;
        }

        const original = this;
        const fake = this.instantiateFake();
        fake.matchingArguments = args;
        fake.parent = this;
        push(this.fakes, fake);

        fake.withArgs = function () {
            return original.withArgs.apply(original, arguments);
        };

        forEach(original.args, function (arg, i) {
            if (!matches(fake, arg)) {
                return;
            }

            proxyCallUtil.incrementCallCount(fake);
            push(fake.thisValues, original.thisValues[i]);
            push(fake.args, arg);
            push(fake.returnValues, original.returnValues[i]);
            push(fake.exceptions, original.exceptions[i]);
            push(fake.callIds, original.callIds[i]);
        });

        proxyCallUtil.createCallProperties(fake);

        return fake;
    },

    // Override proxy default implementation
    matchingFakes: function (args, strict) {
        return filter.call(this.fakes, function (fake) {
            return matches(fake, args, strict);
        });
    },
};

/* eslint-disable @sinonjs/no-prototype-methods/no-prototype-methods */
const delegateToCalls = proxyCallUtil.delegateToCalls;
delegateToCalls(spyApi, "callArg", false, "callArgWith", true, function () {
    throw new Error(
        `${this.toString()} cannot call arg since it was not yet invoked.`,
    );
});
spyApi.callArgWith = spyApi.callArg;
delegateToCalls(spyApi, "callArgOn", false, "callArgOnWith", true, function () {
    throw new Error(
        `${this.toString()} cannot call arg since it was not yet invoked.`,
    );
});
spyApi.callArgOnWith = spyApi.callArgOn;
delegateToCalls(spyApi, "throwArg", false, "throwArg", false, function () {
    throw new Error(
        `${this.toString()} cannot throw arg since it was not yet invoked.`,
    );
});
delegateToCalls(spyApi, "yield", false, "yield", true, function () {
    throw new Error(
        `${this.toString()} cannot yield since it was not yet invoked.`,
    );
});
// "invokeCallback" is an alias for "yield" since "yield" is invalid in strict mode.
spyApi.invokeCallback = spyApi.yield;
delegateToCalls(spyApi, "yieldOn", false, "yieldOn", true, function () {
    throw new Error(
        `${this.toString()} cannot yield since it was not yet invoked.`,
    );
});
delegateToCalls(spyApi, "yieldTo", false, "yieldTo", true, function (property) {
    throw new Error(
        `${this.toString()} cannot yield to '${valueToString(
            property,
        )}' since it was not yet invoked.`,
    );
});
delegateToCalls(
    spyApi,
    "yieldToOn",
    false,
    "yieldToOn",
    true,
    function (property) {
        throw new Error(
            `${this.toString()} cannot yield to '${valueToString(
                property,
            )}' since it was not yet invoked.`,
        );
    },
);

function createSpy(func) {
    let name;
    let funk = func;

    if (typeof funk !== "function") {
        funk = function () {
            return;
        };
    } else {
        name = functionName(funk);
    }

    const proxy = createProxy(funk, funk);

    // Inherit spy API:
    extend.nonEnum(proxy, spyApi);
    extend.nonEnum(proxy, {
        displayName: name || "spy",
        fakes: [],
        instantiateFake: createSpy,
        id: `spy#${uuid++}`,
    });
    return proxy;
}

function spy(object, property, types) {
    if (isEsModule(object)) {
        throw new TypeError("ES Modules cannot be spied");
    }

    if (!property && typeof object === "function") {
        return createSpy(object);
    }

    if (!property && typeof object === "object") {
        return walkObject(spy, object);
    }

    if (!object && !property) {
        return createSpy(function () {
            return;
        });
    }

    if (!types) {
        return wrapMethod(object, property, createSpy(object[property]));
    }

    const descriptor = {};
    const methodDesc = getPropertyDescriptor(object, property);

    forEach(types, function (type) {
        descriptor[type] = createSpy(methodDesc[type]);
    });

    return wrapMethod(object, property, descriptor);
}

extend(spy, spyApi);
module.exports = spy;
