"use strict";

const arrayProto = require("@sinonjs/commons").prototypes.array;
const logger = require("@sinonjs/commons").deprecated;
const collectOwnMethods = require("./collect-own-methods");
const getPropertyDescriptor = require("./util/core/get-property-descriptor");
const isPropertyConfigurable = require("./util/core/is-property-configurable");
const match = require("@sinonjs/samsam").createMatcher;
const sinonAssert = require("./assert");
const sinonClock = require("./util/fake-timers");
const sinonMock = require("./mock");
const sinonSpy = require("./spy");
const sinonStub = require("./stub");
const sinonCreateStubInstance = require("./create-stub-instance");
const sinonFake = require("./fake");
const valueToString = require("@sinonjs/commons").valueToString;
const fakeServer = require("nise").fakeServer;
const fakeXhr = require("nise").fakeXhr;
const usePromiseLibrary = require("./util/core/use-promise-library");

const DEFAULT_LEAK_THRESHOLD = 10000;

const filter = arrayProto.filter;
const forEach = arrayProto.forEach;
const push = arrayProto.push;
const reverse = arrayProto.reverse;

function applyOnEach(fakes, method) {
    const matchingFakes = filter(fakes, function (fake) {
        return typeof fake[method] === "function";
    });

    forEach(matchingFakes, function (fake) {
        fake[method]();
    });
}

function Sandbox() {
    const sandbox = this;
    let fakeRestorers = [];
    let promiseLib;

    let collection = [];
    let loggedLeakWarning = false;
    sandbox.leakThreshold = DEFAULT_LEAK_THRESHOLD;

    function addToCollection(object) {
        if (
            push(collection, object) > sandbox.leakThreshold &&
            !loggedLeakWarning
        ) {
            // eslint-disable-next-line no-console
            logger.printWarning(
                "Potential memory leak detected; be sure to call restore() to clean up your sandbox. To suppress this warning, modify the leakThreshold property of your sandbox."
            );
            loggedLeakWarning = true;
        }
    }

    sandbox.assert = sinonAssert.createAssertObject();

    sandbox.serverPrototype = fakeServer;

    // this is for testing only
    sandbox.getFakes = function getFakes() {
        return collection;
    };

    // this is for testing only
    sandbox.getRestorers = function () {
        return fakeRestorers;
    };

    sandbox.createStubInstance = function createStubInstance() {
        const stubbed = sinonCreateStubInstance.apply(null, arguments);

        const ownMethods = collectOwnMethods(stubbed);

        forEach(ownMethods, function (method) {
            addToCollection(method);
        });

        usePromiseLibrary(promiseLib, ownMethods);

        return stubbed;
    };

    sandbox.inject = function inject(obj) {
        obj.spy = function () {
            return sandbox.spy.apply(null, arguments);
        };

        obj.stub = function () {
            return sandbox.stub.apply(null, arguments);
        };

        obj.mock = function () {
            return sandbox.mock.apply(null, arguments);
        };

        obj.createStubInstance = function () {
            return sandbox.createStubInstance.apply(sandbox, arguments);
        };

        obj.fake = function () {
            return sandbox.fake.apply(null, arguments);
        };

        obj.replace = function () {
            return sandbox.replace.apply(null, arguments);
        };

        obj.replaceSetter = function () {
            return sandbox.replaceSetter.apply(null, arguments);
        };

        obj.replaceGetter = function () {
            return sandbox.replaceGetter.apply(null, arguments);
        };

        if (sandbox.clock) {
            obj.clock = sandbox.clock;
        }

        if (sandbox.server) {
            obj.server = sandbox.server;
            obj.requests = sandbox.server.requests;
        }

        obj.match = match;

        return obj;
    };

    sandbox.mock = function mock() {
        const m = sinonMock.apply(null, arguments);

        addToCollection(m);
        usePromiseLibrary(promiseLib, m);

        return m;
    };

    sandbox.reset = function reset() {
        applyOnEach(collection, "reset");
        applyOnEach(collection, "resetHistory");
    };

    sandbox.resetBehavior = function resetBehavior() {
        applyOnEach(collection, "resetBehavior");
    };

    sandbox.resetHistory = function resetHistory() {
        function privateResetHistory(f) {
            const method = f.resetHistory || f.reset;
            if (method) {
                method.call(f);
            }
        }

        forEach(collection, function (fake) {
            if (typeof fake === "function") {
                privateResetHistory(fake);
                return;
            }

            const methods = [];
            if (fake.get) {
                push(methods, fake.get);
            }

            if (fake.set) {
                push(methods, fake.set);
            }

            forEach(methods, privateResetHistory);
        });
    };

    sandbox.restore = function restore() {
        if (arguments.length) {
            throw new Error(
                "sandbox.restore() does not take any parameters. Perhaps you meant stub.restore()"
            );
        }

        reverse(collection);
        applyOnEach(collection, "restore");
        collection = [];

        forEach(fakeRestorers, function (restorer) {
            restorer();
        });
        fakeRestorers = [];

        sandbox.restoreContext();
    };

    sandbox.restoreContext = function restoreContext() {
        let injectedKeys = sandbox.injectedKeys;
        const injectInto = sandbox.injectInto;

        if (!injectedKeys) {
            return;
        }

        forEach(injectedKeys, function (injectedKey) {
            delete injectInto[injectedKey];
        });

        injectedKeys = [];
    };

    function getFakeRestorer(object, property) {
        const descriptor = getPropertyDescriptor(object, property);

        function restorer() {
            if (descriptor.isOwn) {
                Object.defineProperty(object, property, descriptor);
            } else {
                delete object[property];
            }
        }
        restorer.object = object;
        restorer.property = property;
        return restorer;
    }

    function verifyNotReplaced(object, property) {
        forEach(fakeRestorers, function (fakeRestorer) {
            if (
                fakeRestorer.object === object &&
                fakeRestorer.property === property
            ) {
                throw new TypeError(
                    `Attempted to replace ${property} which is already replaced`
                );
            }
        });
    }

    sandbox.replace = function replace(object, property, replacement) {
        const descriptor = getPropertyDescriptor(object, property);

        if (typeof descriptor === "undefined") {
            throw new TypeError(
                `Cannot replace non-existent property ${valueToString(
                    property
                )}`
            );
        }

        if (typeof replacement === "undefined") {
            throw new TypeError("Expected replacement argument to be defined");
        }

        if (typeof descriptor.get === "function") {
            throw new Error("Use sandbox.replaceGetter for replacing getters");
        }

        if (typeof descriptor.set === "function") {
            throw new Error("Use sandbox.replaceSetter for replacing setters");
        }

        if (typeof object[property] !== typeof replacement) {
            throw new TypeError(
                `Cannot replace ${typeof object[
                    property
                ]} with ${typeof replacement}`
            );
        }

        verifyNotReplaced(object, property);

        // store a function for restoring the replaced property
        push(fakeRestorers, getFakeRestorer(object, property));

        object[property] = replacement;

        return replacement;
    };

    sandbox.replaceGetter = function replaceGetter(
        object,
        property,
        replacement
    ) {
        const descriptor = getPropertyDescriptor(object, property);

        if (typeof descriptor === "undefined") {
            throw new TypeError(
                `Cannot replace non-existent property ${valueToString(
                    property
                )}`
            );
        }

        if (typeof replacement !== "function") {
            throw new TypeError(
                "Expected replacement argument to be a function"
            );
        }

        if (typeof descriptor.get !== "function") {
            throw new Error("`object.property` is not a getter");
        }

        verifyNotReplaced(object, property);

        // store a function for restoring the replaced property
        push(fakeRestorers, getFakeRestorer(object, property));

        Object.defineProperty(object, property, {
            get: replacement,
            configurable: isPropertyConfigurable(object, property),
        });

        return replacement;
    };

    sandbox.replaceSetter = function replaceSetter(
        object,
        property,
        replacement
    ) {
        const descriptor = getPropertyDescriptor(object, property);

        if (typeof descriptor === "undefined") {
            throw new TypeError(
                `Cannot replace non-existent property ${valueToString(
                    property
                )}`
            );
        }

        if (typeof replacement !== "function") {
            throw new TypeError(
                "Expected replacement argument to be a function"
            );
        }

        if (typeof descriptor.set !== "function") {
            throw new Error("`object.property` is not a setter");
        }

        verifyNotReplaced(object, property);

        // store a function for restoring the replaced property
        push(fakeRestorers, getFakeRestorer(object, property));

        // eslint-disable-next-line accessor-pairs
        Object.defineProperty(object, property, {
            set: replacement,
            configurable: isPropertyConfigurable(object, property),
        });

        return replacement;
    };

    function commonPostInitSetup(args, spy) {
        const object = args[0];
        const property = args[1];

        const isSpyingOnEntireObject =
            typeof property === "undefined" && typeof object === "object";

        if (isSpyingOnEntireObject) {
            const ownMethods = collectOwnMethods(spy);

            forEach(ownMethods, function (method) {
                addToCollection(method);
            });

            usePromiseLibrary(promiseLib, ownMethods);
        } else {
            addToCollection(spy);
            usePromiseLibrary(promiseLib, spy);
        }

        return spy;
    }

    sandbox.spy = function spy() {
        const createdSpy = sinonSpy.apply(sinonSpy, arguments);
        return commonPostInitSetup(arguments, createdSpy);
    };

    sandbox.stub = function stub() {
        const createdStub = sinonStub.apply(sinonStub, arguments);
        return commonPostInitSetup(arguments, createdStub);
    };

    // eslint-disable-next-line no-unused-vars
    sandbox.fake = function fake(f) {
        const s = sinonFake.apply(sinonFake, arguments);

        addToCollection(s);

        return s;
    };

    forEach(Object.keys(sinonFake), function (key) {
        const fakeBehavior = sinonFake[key];
        if (typeof fakeBehavior === "function") {
            sandbox.fake[key] = function () {
                const s = fakeBehavior.apply(fakeBehavior, arguments);

                addToCollection(s);

                return s;
            };
        }
    });

    sandbox.useFakeTimers = function useFakeTimers(args) {
        const clock = sinonClock.useFakeTimers.call(null, args);

        sandbox.clock = clock;
        addToCollection(clock);

        return clock;
    };

    sandbox.verify = function verify() {
        applyOnEach(collection, "verify");
    };

    sandbox.verifyAndRestore = function verifyAndRestore() {
        let exception;

        try {
            sandbox.verify();
        } catch (e) {
            exception = e;
        }

        sandbox.restore();

        if (exception) {
            throw exception;
        }
    };

    sandbox.useFakeServer = function useFakeServer() {
        const proto = sandbox.serverPrototype || fakeServer;

        if (!proto || !proto.create) {
            return null;
        }

        sandbox.server = proto.create();
        addToCollection(sandbox.server);

        return sandbox.server;
    };

    sandbox.useFakeXMLHttpRequest = function useFakeXMLHttpRequest() {
        const xhr = fakeXhr.useFakeXMLHttpRequest();
        addToCollection(xhr);
        return xhr;
    };

    sandbox.usingPromise = function usingPromise(promiseLibrary) {
        promiseLib = promiseLibrary;
        collection.promiseLibrary = promiseLibrary;

        return sandbox;
    };
}

Sandbox.prototype.match = match;

module.exports = Sandbox;
