"use strict";

const arrayProto = require("@sinonjs/commons").prototypes.array;
const proxyInvoke = require("./proxy-invoke");
const proxyCallToString = require("./proxy-call").toString;
const timesInWords = require("./util/core/times-in-words");
const extend = require("./util/core/extend");
const match = require("@sinonjs/samsam").createMatcher;
const stub = require("./stub");
const assert = require("./assert");
const deepEqual = require("@sinonjs/samsam").deepEqual;
const inspect = require("util").inspect;
const valueToString = require("@sinonjs/commons").valueToString;

const every = arrayProto.every;
const forEach = arrayProto.forEach;
const push = arrayProto.push;
const slice = arrayProto.slice;

function callCountInWords(callCount) {
    if (callCount === 0) {
        return "never called";
    }

    return `called ${timesInWords(callCount)}`;
}

function expectedCallCountInWords(expectation) {
    const min = expectation.minCalls;
    const max = expectation.maxCalls;

    if (typeof min === "number" && typeof max === "number") {
        let str = timesInWords(min);

        if (min !== max) {
            str = `at least ${str} and at most ${timesInWords(max)}`;
        }

        return str;
    }

    if (typeof min === "number") {
        return `at least ${timesInWords(min)}`;
    }

    return `at most ${timesInWords(max)}`;
}

function receivedMinCalls(expectation) {
    const hasMinLimit = typeof expectation.minCalls === "number";
    return !hasMinLimit || expectation.callCount >= expectation.minCalls;
}

function receivedMaxCalls(expectation) {
    if (typeof expectation.maxCalls !== "number") {
        return false;
    }

    return expectation.callCount === expectation.maxCalls;
}

function verifyMatcher(possibleMatcher, arg) {
    const isMatcher = match.isMatcher(possibleMatcher);

    return (isMatcher && possibleMatcher.test(arg)) || true;
}

const mockExpectation = {
    minCalls: 1,
    maxCalls: 1,

    create: function create(methodName) {
        const expectation = extend.nonEnum(stub(), mockExpectation);
        delete expectation.create;
        expectation.method = methodName;

        return expectation;
    },

    invoke: function invoke(func, thisValue, args) {
        this.verifyCallAllowed(thisValue, args);

        return proxyInvoke.apply(this, arguments);
    },

    atLeast: function atLeast(num) {
        if (typeof num !== "number") {
            throw new TypeError(`'${valueToString(num)}' is not number`);
        }

        if (!this.limitsSet) {
            this.maxCalls = null;
            this.limitsSet = true;
        }

        this.minCalls = num;

        return this;
    },

    atMost: function atMost(num) {
        if (typeof num !== "number") {
            throw new TypeError(`'${valueToString(num)}' is not number`);
        }

        if (!this.limitsSet) {
            this.minCalls = null;
            this.limitsSet = true;
        }

        this.maxCalls = num;

        return this;
    },

    never: function never() {
        return this.exactly(0);
    },

    once: function once() {
        return this.exactly(1);
    },

    twice: function twice() {
        return this.exactly(2);
    },

    thrice: function thrice() {
        return this.exactly(3);
    },

    exactly: function exactly(num) {
        if (typeof num !== "number") {
            throw new TypeError(`'${valueToString(num)}' is not a number`);
        }

        this.atLeast(num);
        return this.atMost(num);
    },

    met: function met() {
        return !this.failed && receivedMinCalls(this);
    },

    verifyCallAllowed: function verifyCallAllowed(thisValue, args) {
        const expectedArguments = this.expectedArguments;

        if (receivedMaxCalls(this)) {
            this.failed = true;
            mockExpectation.fail(
                `${this.method} already called ${timesInWords(this.maxCalls)}`
            );
        }

        if ("expectedThis" in this && this.expectedThis !== thisValue) {
            mockExpectation.fail(
                `${this.method} called with ${valueToString(
                    thisValue
                )} as thisValue, expected ${valueToString(this.expectedThis)}`
            );
        }

        if (!("expectedArguments" in this)) {
            return;
        }

        if (!args) {
            mockExpectation.fail(
                `${this.method} received no arguments, expected ${inspect(
                    expectedArguments
                )}`
            );
        }

        if (args.length < expectedArguments.length) {
            mockExpectation.fail(
                `${this.method} received too few arguments (${inspect(
                    args
                )}), expected ${inspect(expectedArguments)}`
            );
        }

        if (
            this.expectsExactArgCount &&
            args.length !== expectedArguments.length
        ) {
            mockExpectation.fail(
                `${this.method} received too many arguments (${inspect(
                    args
                )}), expected ${inspect(expectedArguments)}`
            );
        }

        forEach(
            expectedArguments,
            function (expectedArgument, i) {
                if (!verifyMatcher(expectedArgument, args[i])) {
                    mockExpectation.fail(
                        `${this.method} received wrong arguments ${inspect(
                            args
                        )}, didn't match ${String(expectedArguments)}`
                    );
                }

                if (!deepEqual(args[i], expectedArgument)) {
                    mockExpectation.fail(
                        `${this.method} received wrong arguments ${inspect(
                            args
                        )}, expected ${inspect(expectedArguments)}`
                    );
                }
            },
            this
        );
    },

    allowsCall: function allowsCall(thisValue, args) {
        const expectedArguments = this.expectedArguments;

        if (this.met() && receivedMaxCalls(this)) {
            return false;
        }

        if ("expectedThis" in this && this.expectedThis !== thisValue) {
            return false;
        }

        if (!("expectedArguments" in this)) {
            return true;
        }

        // eslint-disable-next-line no-underscore-dangle
        const _args = args || [];

        if (_args.length < expectedArguments.length) {
            return false;
        }

        if (
            this.expectsExactArgCount &&
            _args.length !== expectedArguments.length
        ) {
            return false;
        }

        return every(expectedArguments, function (expectedArgument, i) {
            if (!verifyMatcher(expectedArgument, _args[i])) {
                return false;
            }

            if (!deepEqual(_args[i], expectedArgument)) {
                return false;
            }

            return true;
        });
    },

    withArgs: function withArgs() {
        this.expectedArguments = slice(arguments);
        return this;
    },

    withExactArgs: function withExactArgs() {
        this.withArgs.apply(this, arguments);
        this.expectsExactArgCount = true;
        return this;
    },

    on: function on(thisValue) {
        this.expectedThis = thisValue;
        return this;
    },

    toString: function () {
        const args = slice(this.expectedArguments || []);

        if (!this.expectsExactArgCount) {
            push(args, "[...]");
        }

        const callStr = proxyCallToString.call({
            proxy: this.method || "anonymous mock expectation",
            args: args,
        });

        const message = `${callStr.replace(
            ", [...",
            "[, ..."
        )} ${expectedCallCountInWords(this)}`;

        if (this.met()) {
            return `Expectation met: ${message}`;
        }

        return `Expected ${message} (${callCountInWords(this.callCount)})`;
    },

    verify: function verify() {
        if (!this.met()) {
            mockExpectation.fail(String(this));
        } else {
            mockExpectation.pass(String(this));
        }

        return true;
    },

    pass: function pass(message) {
        assert.pass(message);
    },

    fail: function fail(message) {
        const exception = new Error(message);
        exception.name = "ExpectationError";

        throw exception;
    },
};

module.exports = mockExpectation;
