"use strict";

var arrayProto = require("@sinonjs/commons").prototypes.array;
var deepEqual = require("./deep-equal").use(createMatcher); // eslint-disable-line no-use-before-define
var every = require("@sinonjs/commons").every;
var functionName = require("@sinonjs/commons").functionName;
var get = require("lodash.get");
var iterableToString = require("./iterable-to-string");
var objectProto = require("@sinonjs/commons").prototypes.object;
var typeOf = require("@sinonjs/commons").typeOf;
var valueToString = require("@sinonjs/commons").valueToString;

var assertMatcher = require("./create-matcher/assert-matcher");
var assertMethodExists = require("./create-matcher/assert-method-exists");
var assertType = require("./create-matcher/assert-type");
var isIterable = require("./create-matcher/is-iterable");
var isMatcher = require("./create-matcher/is-matcher");

var matcherPrototype = require("./create-matcher/matcher-prototype");

var arrayIndexOf = arrayProto.indexOf;
var some = arrayProto.some;

var hasOwnProperty = objectProto.hasOwnProperty;
var objectToString = objectProto.toString;

var TYPE_MAP = require("./create-matcher/type-map")(createMatcher); // eslint-disable-line no-use-before-define

/**
 * Creates a matcher object for the passed expectation
 *
 * @alias module:samsam.createMatcher
 * @param {*} expectation An expecttation
 * @param {string} message A message for the expectation
 * @returns {object} A matcher object
 */
function createMatcher(expectation, message) {
    var m = Object.create(matcherPrototype);
    var type = typeOf(expectation);

    if (message !== undefined && typeof message !== "string") {
        throw new TypeError("Message should be a string");
    }

    if (arguments.length > 2) {
        throw new TypeError(
            `Expected 1 or 2 arguments, received ${arguments.length}`
        );
    }

    if (type in TYPE_MAP) {
        TYPE_MAP[type](m, expectation, message);
    } else {
        m.test = function (actual) {
            return deepEqual(actual, expectation);
        };
    }

    if (!m.message) {
        m.message = `match(${valueToString(expectation)})`;
    }

    // ensure that nothing mutates the exported message value, ref https://github.com/sinonjs/sinon/issues/2502
    Object.defineProperty(m, "message", {
        configurable: false,
        writable: false,
        value: m.message,
    });

    return m;
}

createMatcher.isMatcher = isMatcher;

createMatcher.any = createMatcher(function () {
    return true;
}, "any");

createMatcher.defined = createMatcher(function (actual) {
    return actual !== null && actual !== undefined;
}, "defined");

createMatcher.truthy = createMatcher(function (actual) {
    return Boolean(actual);
}, "truthy");

createMatcher.falsy = createMatcher(function (actual) {
    return !actual;
}, "falsy");

createMatcher.same = function (expectation) {
    return createMatcher(function (actual) {
        return expectation === actual;
    }, `same(${valueToString(expectation)})`);
};

createMatcher.in = function (arrayOfExpectations) {
    if (typeOf(arrayOfExpectations) !== "array") {
        throw new TypeError("array expected");
    }

    return createMatcher(function (actual) {
        return some(arrayOfExpectations, function (expectation) {
            return expectation === actual;
        });
    }, `in(${valueToString(arrayOfExpectations)})`);
};

createMatcher.typeOf = function (type) {
    assertType(type, "string", "type");
    return createMatcher(function (actual) {
        return typeOf(actual) === type;
    }, `typeOf("${type}")`);
};

createMatcher.instanceOf = function (type) {
    /* istanbul ignore if */
    if (
        typeof Symbol === "undefined" ||
        typeof Symbol.hasInstance === "undefined"
    ) {
        assertType(type, "function", "type");
    } else {
        assertMethodExists(
            type,
            Symbol.hasInstance,
            "type",
            "[Symbol.hasInstance]"
        );
    }
    return createMatcher(function (actual) {
        return actual instanceof type;
    }, `instanceOf(${functionName(type) || objectToString(type)})`);
};

/**
 * Creates a property matcher
 *
 * @private
 * @param {Function} propertyTest A function to test the property against a value
 * @param {string} messagePrefix A prefix to use for messages generated by the matcher
 * @returns {object} A matcher
 */
function createPropertyMatcher(propertyTest, messagePrefix) {
    return function (property, value) {
        assertType(property, "string", "property");
        var onlyProperty = arguments.length === 1;
        var message = `${messagePrefix}("${property}"`;
        if (!onlyProperty) {
            message += `, ${valueToString(value)}`;
        }
        message += ")";
        return createMatcher(function (actual) {
            if (
                actual === undefined ||
                actual === null ||
                !propertyTest(actual, property)
            ) {
                return false;
            }
            return onlyProperty || deepEqual(actual[property], value);
        }, message);
    };
}

createMatcher.has = createPropertyMatcher(function (actual, property) {
    if (typeof actual === "object") {
        return property in actual;
    }
    return actual[property] !== undefined;
}, "has");

createMatcher.hasOwn = createPropertyMatcher(function (actual, property) {
    return hasOwnProperty(actual, property);
}, "hasOwn");

createMatcher.hasNested = function (property, value) {
    assertType(property, "string", "property");
    var onlyProperty = arguments.length === 1;
    var message = `hasNested("${property}"`;
    if (!onlyProperty) {
        message += `, ${valueToString(value)}`;
    }
    message += ")";
    return createMatcher(function (actual) {
        if (
            actual === undefined ||
            actual === null ||
            get(actual, property) === undefined
        ) {
            return false;
        }
        return onlyProperty || deepEqual(get(actual, property), value);
    }, message);
};

var jsonParseResultTypes = {
    null: true,
    boolean: true,
    number: true,
    string: true,
    object: true,
    array: true,
};
createMatcher.json = function (value) {
    if (!jsonParseResultTypes[typeOf(value)]) {
        throw new TypeError("Value cannot be the result of JSON.parse");
    }
    var message = `json(${JSON.stringify(value, null, "  ")})`;
    return createMatcher(function (actual) {
        var parsed;
        try {
            parsed = JSON.parse(actual);
        } catch (e) {
            return false;
        }
        return deepEqual(parsed, value);
    }, message);
};

createMatcher.every = function (predicate) {
    assertMatcher(predicate);

    return createMatcher(function (actual) {
        if (typeOf(actual) === "object") {
            return every(Object.keys(actual), function (key) {
                return predicate.test(actual[key]);
            });
        }

        return (
            isIterable(actual) &&
            every(actual, function (element) {
                return predicate.test(element);
            })
        );
    }, `every(${predicate.message})`);
};

createMatcher.some = function (predicate) {
    assertMatcher(predicate);

    return createMatcher(function (actual) {
        if (typeOf(actual) === "object") {
            return !every(Object.keys(actual), function (key) {
                return !predicate.test(actual[key]);
            });
        }

        return (
            isIterable(actual) &&
            !every(actual, function (element) {
                return !predicate.test(element);
            })
        );
    }, `some(${predicate.message})`);
};

createMatcher.array = createMatcher.typeOf("array");

createMatcher.array.deepEquals = function (expectation) {
    return createMatcher(function (actual) {
        // Comparing lengths is the fastest way to spot a difference before iterating through every item
        var sameLength = actual.length === expectation.length;
        return (
            typeOf(actual) === "array" &&
            sameLength &&
            every(actual, function (element, index) {
                var expected = expectation[index];
                return typeOf(expected) === "array" &&
                    typeOf(element) === "array"
                    ? createMatcher.array.deepEquals(expected).test(element)
                    : deepEqual(expected, element);
            })
        );
    }, `deepEquals([${iterableToString(expectation)}])`);
};

createMatcher.array.startsWith = function (expectation) {
    return createMatcher(function (actual) {
        return (
            typeOf(actual) === "array" &&
            every(expectation, function (expectedElement, index) {
                return actual[index] === expectedElement;
            })
        );
    }, `startsWith([${iterableToString(expectation)}])`);
};

createMatcher.array.endsWith = function (expectation) {
    return createMatcher(function (actual) {
        // This indicates the index in which we should start matching
        var offset = actual.length - expectation.length;

        return (
            typeOf(actual) === "array" &&
            every(expectation, function (expectedElement, index) {
                return actual[offset + index] === expectedElement;
            })
        );
    }, `endsWith([${iterableToString(expectation)}])`);
};

createMatcher.array.contains = function (expectation) {
    return createMatcher(function (actual) {
        return (
            typeOf(actual) === "array" &&
            every(expectation, function (expectedElement) {
                return arrayIndexOf(actual, expectedElement) !== -1;
            })
        );
    }, `contains([${iterableToString(expectation)}])`);
};

createMatcher.map = createMatcher.typeOf("map");

createMatcher.map.deepEquals = function mapDeepEquals(expectation) {
    return createMatcher(function (actual) {
        // Comparing lengths is the fastest way to spot a difference before iterating through every item
        var sameLength = actual.size === expectation.size;
        return (
            typeOf(actual) === "map" &&
            sameLength &&
            every(actual, function (element, key) {
                return expectation.has(key) && expectation.get(key) === element;
            })
        );
    }, `deepEquals(Map[${iterableToString(expectation)}])`);
};

createMatcher.map.contains = function mapContains(expectation) {
    return createMatcher(function (actual) {
        return (
            typeOf(actual) === "map" &&
            every(expectation, function (element, key) {
                return actual.has(key) && actual.get(key) === element;
            })
        );
    }, `contains(Map[${iterableToString(expectation)}])`);
};

createMatcher.set = createMatcher.typeOf("set");

createMatcher.set.deepEquals = function setDeepEquals(expectation) {
    return createMatcher(function (actual) {
        // Comparing lengths is the fastest way to spot a difference before iterating through every item
        var sameLength = actual.size === expectation.size;
        return (
            typeOf(actual) === "set" &&
            sameLength &&
            every(actual, function (element) {
                return expectation.has(element);
            })
        );
    }, `deepEquals(Set[${iterableToString(expectation)}])`);
};

createMatcher.set.contains = function setContains(expectation) {
    return createMatcher(function (actual) {
        return (
            typeOf(actual) === "set" &&
            every(expectation, function (element) {
                return actual.has(element);
            })
        );
    }, `contains(Set[${iterableToString(expectation)}])`);
};

createMatcher.bool = createMatcher.typeOf("boolean");
createMatcher.number = createMatcher.typeOf("number");
createMatcher.string = createMatcher.typeOf("string");
createMatcher.object = createMatcher.typeOf("object");
createMatcher.func = createMatcher.typeOf("function");
createMatcher.regexp = createMatcher.typeOf("regexp");
createMatcher.date = createMatcher.typeOf("date");
createMatcher.symbol = createMatcher.typeOf("symbol");

module.exports = createMatcher;
