"use strict";
/** @module */

const arrayProto = require("@sinonjs/commons").prototypes.array;
const calledInOrder = require("@sinonjs/commons").calledInOrder;
const createMatcher = require("@sinonjs/samsam").createMatcher;
const orderByFirstCall = require("@sinonjs/commons").orderByFirstCall;
const timesInWords = require("./util/core/times-in-words");
const inspect = require("util").inspect;
const stringSlice = require("@sinonjs/commons").prototypes.string.slice;
const globalObject = require("@sinonjs/commons").global;

const arraySlice = arrayProto.slice;
const concat = arrayProto.concat;
const forEach = arrayProto.forEach;
const join = arrayProto.join;
const splice = arrayProto.splice;

function applyDefaults(obj, defaults) {
    for (const key of Object.keys(defaults)) {
        const val = obj[key];
        if (val === null || typeof val === "undefined") {
            obj[key] = defaults[key];
        }
    }
}

/**
 * @typedef {object} CreateAssertOptions
 * @global
 *
 * @property {boolean} [shouldLimitAssertionLogs] default is false
 * @property {number}  [assertionLogLimit] default is 10K
 */

/**
 * Create an assertion object that exposes several methods to invoke
 *
 * @param {CreateAssertOptions}  [opts] options bag
 * @returns {object} object with multiple assertion methods
 */
function createAssertObject(opts) {
    const cleanedAssertOptions = opts || {};
    applyDefaults(cleanedAssertOptions, {
        shouldLimitAssertionLogs: false,
        assertionLogLimit: 1e4,
    });

    const assert = {
        failException: "AssertError",

        fail: function fail(message) {
            let msg = message;
            if (cleanedAssertOptions.shouldLimitAssertionLogs) {
                msg = message.substring(
                    0,
                    cleanedAssertOptions.assertionLogLimit,
                );
            }
            const error = new Error(msg);
            error.name = this.failException || assert.failException;

            throw error;
        },

        pass: function pass() {
            return;
        },

        callOrder: function assertCallOrder() {
            verifyIsStub.apply(null, arguments);
            let expected = "";
            let actual = "";

            if (!calledInOrder(arguments)) {
                try {
                    expected = join(arguments, ", ");
                    const calls = arraySlice(arguments);
                    let i = calls.length;
                    while (i) {
                        if (!calls[--i].called) {
                            splice(calls, i, 1);
                        }
                    }
                    actual = join(orderByFirstCall(calls), ", ");
                } catch (e) {
                    // If this fails, we'll just fall back to the blank string
                }

                failAssertion(
                    this,
                    `expected ${expected} to be called in order but were called as ${actual}`,
                );
            } else {
                assert.pass("callOrder");
            }
        },

        callCount: function assertCallCount(method, count) {
            verifyIsStub(method);

            let msg;
            if (typeof count !== "number") {
                msg =
                    `expected ${inspect(count)} to be a number ` +
                    `but was of type ${typeof count}`;
                failAssertion(this, msg);
            } else if (method.callCount !== count) {
                msg =
                    `expected %n to be called ${timesInWords(count)} ` +
                    `but was called %c%C`;
                failAssertion(this, method.printf(msg));
            } else {
                assert.pass("callCount");
            }
        },

        expose: function expose(target, options) {
            if (!target) {
                throw new TypeError("target is null or undefined");
            }

            const o = options || {};
            const prefix =
                (typeof o.prefix === "undefined" && "assert") || o.prefix;
            const includeFail =
                typeof o.includeFail === "undefined" || Boolean(o.includeFail);
            const instance = this;

            forEach(Object.keys(instance), function (method) {
                if (
                    method !== "expose" &&
                    (includeFail || !/^(fail)/.test(method))
                ) {
                    target[exposedName(prefix, method)] = instance[method];
                }
            });

            return target;
        },

        match: function match(actual, expectation) {
            const matcher = createMatcher(expectation);
            if (matcher.test(actual)) {
                assert.pass("match");
            } else {
                const formatted = [
                    "expected value to match",
                    `    expected = ${inspect(expectation)}`,
                    `    actual = ${inspect(actual)}`,
                ];

                failAssertion(this, join(formatted, "\n"));
            }
        },
    };

    function verifyIsStub() {
        const args = arraySlice(arguments);

        forEach(args, function (method) {
            if (!method) {
                assert.fail("fake is not a spy");
            }

            if (method.proxy && method.proxy.isSinonProxy) {
                verifyIsStub(method.proxy);
            } else {
                if (typeof method !== "function") {
                    assert.fail(`${method} is not a function`);
                }

                if (typeof method.getCall !== "function") {
                    assert.fail(`${method} is not stubbed`);
                }
            }
        });
    }

    function verifyIsValidAssertion(assertionMethod, assertionArgs) {
        switch (assertionMethod) {
            case "notCalled":
            case "called":
            case "calledOnce":
            case "calledTwice":
            case "calledThrice":
                if (assertionArgs.length !== 0) {
                    assert.fail(
                        `${assertionMethod} takes 1 argument but was called with ${
                            assertionArgs.length + 1
                        } arguments`,
                    );
                }
                break;
            default:
                break;
        }
    }

    function failAssertion(object, msg) {
        const obj = object || globalObject;
        const failMethod = obj.fail || assert.fail;
        failMethod.call(obj, msg);
    }

    function mirrorPropAsAssertion(name, method, message) {
        let msg = message;
        let meth = method;
        if (arguments.length === 2) {
            msg = method;
            meth = name;
        }

        assert[name] = function (fake) {
            verifyIsStub(fake);

            const args = arraySlice(arguments, 1);
            let failed = false;

            verifyIsValidAssertion(name, args);

            if (typeof meth === "function") {
                failed = !meth(fake);
            } else {
                failed =
                    typeof fake[meth] === "function"
                        ? !fake[meth].apply(fake, args)
                        : !fake[meth];
            }

            if (failed) {
                failAssertion(
                    this,
                    (fake.printf || fake.proxy.printf).apply(
                        fake,
                        concat([msg], args),
                    ),
                );
            } else {
                assert.pass(name);
            }
        };
    }

    function exposedName(prefix, prop) {
        return !prefix || /^fail/.test(prop)
            ? prop
            : prefix +
                  stringSlice(prop, 0, 1).toUpperCase() +
                  stringSlice(prop, 1);
    }

    mirrorPropAsAssertion(
        "called",
        "expected %n to have been called at least once but was never called",
    );
    mirrorPropAsAssertion(
        "notCalled",
        function (spy) {
            return !spy.called;
        },
        "expected %n to not have been called but was called %c%C",
    );
    mirrorPropAsAssertion(
        "calledOnce",
        "expected %n to be called once but was called %c%C",
    );
    mirrorPropAsAssertion(
        "calledTwice",
        "expected %n to be called twice but was called %c%C",
    );
    mirrorPropAsAssertion(
        "calledThrice",
        "expected %n to be called thrice but was called %c%C",
    );
    mirrorPropAsAssertion(
        "calledOn",
        "expected %n to be called with %1 as this but was called with %t",
    );
    mirrorPropAsAssertion(
        "alwaysCalledOn",
        "expected %n to always be called with %1 as this but was called with %t",
    );
    mirrorPropAsAssertion("calledWithNew", "expected %n to be called with new");
    mirrorPropAsAssertion(
        "alwaysCalledWithNew",
        "expected %n to always be called with new",
    );
    mirrorPropAsAssertion(
        "calledWith",
        "expected %n to be called with arguments %D",
    );
    mirrorPropAsAssertion(
        "calledWithMatch",
        "expected %n to be called with match %D",
    );
    mirrorPropAsAssertion(
        "alwaysCalledWith",
        "expected %n to always be called with arguments %D",
    );
    mirrorPropAsAssertion(
        "alwaysCalledWithMatch",
        "expected %n to always be called with match %D",
    );
    mirrorPropAsAssertion(
        "calledWithExactly",
        "expected %n to be called with exact arguments %D",
    );
    mirrorPropAsAssertion(
        "calledOnceWithExactly",
        "expected %n to be called once and with exact arguments %D",
    );
    mirrorPropAsAssertion(
        "calledOnceWithMatch",
        "expected %n to be called once and with match %D",
    );
    mirrorPropAsAssertion(
        "alwaysCalledWithExactly",
        "expected %n to always be called with exact arguments %D",
    );
    mirrorPropAsAssertion(
        "neverCalledWith",
        "expected %n to never be called with arguments %*%C",
    );
    mirrorPropAsAssertion(
        "neverCalledWithMatch",
        "expected %n to never be called with match %*%C",
    );
    mirrorPropAsAssertion("threw", "%n did not throw exception%C");
    mirrorPropAsAssertion("alwaysThrew", "%n did not always throw exception%C");

    return assert;
}

module.exports = createAssertObject();
module.exports.createAssertObject = createAssertObject;
