import _ from 'lodash';

import validator from 'bitbucket/internal/bbui/json-validation/json-validation';

QUnit.module('bitbucket-json-validation');

function validate(assert, isPassing, type, value) {
    const validateType = validator(type);
    try {
        validateType(value);
        assert.ok(isPassing, JSON.stringify(value) + ' validated as ' + type);
    } catch (e) {
        assert.ok(!isPassing, JSON.stringify(value) + ' failed to validate as ' + type + ': ' + e.message);
    }
}

/**
 * Takes in an expected value and an array of [ type, array of values to check ]. yes that
 * is three layers of array:
 * [
 *   [
 *     descriptor for validation,
 *     [...values to be validated]
 *   ]
 * ]
 *
 * @param {Object} assert - The QUnit assert object
 * @param {boolean} isPassing - Is the validation passing
 * @param {Array} validations - the validations to bind
 *
 */
function shouldValidate(assert, isPassing, validations) {
    validations.forEach(function([type, values]) {
        values.forEach(validate.bind(null, assert, isPassing, type));
    });
}

QUnit.test('validates primitives', function(assert) {
    shouldValidate(assert, true, [
        ["string", ['', 'str']],
        ["boolean", [true, false]],
        ["number", [0, 1, -1, Infinity, -Infinity, NaN]],
        ["string?", ['', 'str', null, undefined]],
        ["boolean?", [true, false, null, undefined]],
        ["number?", [0, 1, -1, Infinity, -Infinity, NaN, null, undefined]],
        ["?string", ['', 'str', null, undefined]],
        ["?boolean", [true, false, null, undefined]],
        ["?number", [0, 1, -1, Infinity, -Infinity, NaN, null, undefined]],
    ]);
    shouldValidate(assert, false, [
        ["string", [0, {}, [], ['a'], true, null, undefined]],
        ["boolean", ['true', 'false', 0, 1, {}, [], null, undefined]],
        ["number", [true, false, '0', '1', 'Infinity', [], {}, null, undefined]],
        ["string?", [0, {}, [], ['a'], true]],
        ["boolean?", ['true', 'false', 0, 1, {}, []]],
        ["number?", [true, false, '0', '1', 'Infinity', [], {}]],
        ["?string", [0, {}, [], ['a'], true]],
        ["?boolean", ['true', 'false', 0, 1, {}, []]],
        ["?number", [true, false, '0', '1', 'Infinity', [], {}]],
    ]);
});

QUnit.test('allows custom validation', function(assert) {
    function custom() {}
    assert.strictEqual(validator(custom), custom, 'Given a custom function, returns that function');
});

QUnit.test('if validator is falsy, passes everything', function(assert) {
    const types = [0, false, null, undefined, ''];
    const values = [0, false, null, undefined, '', 1, true, 'str', {}, []];
    types.forEach(function(type) {
        values.forEach(validate.bind(null, assert, true, type));
    });
});

QUnit.test('validates Arrays', function(assert) {
    const stringArrays = [[''], ['', 'a']];
    const mixedArrays = [[0], ['', 0], ['', 'a', 0]];
    const nonEmptyArrays = stringArrays.concat(mixedArrays);
    const allArrays = nonEmptyArrays.concat([[]]);
    const nonArrays = [0, false, null, undefined, '', 1, true, 'str', {}];
    shouldValidate(assert, true, [
        [Array, allArrays],
        [[], allArrays],
        [[0], allArrays],
        [[false], allArrays],
        [[null], allArrays],
        [[undefined], allArrays],
        [['string'], stringArrays.concat([[]])],
        [[() => {}], allArrays],
        [[() => {
            throw new Error('Custom failure, unexpected!');
        }], [[]]],
    ]);
    shouldValidate(assert, false, [
        [Array, nonArrays],
        [[], nonArrays],
        [['string'], nonArrays.concat(mixedArrays)],
        [[() => {
            throw new Error('Custom failure, as expected.');
        }], nonEmptyArrays],
    ]);
});


QUnit.test('validates Objects', function(assert) {
    function Ctor() {}
    const primitives = [0, false, null, undefined, '', 1, true, 'str'];
    const thingObject = { thing: 'str' };
    const nonThingObjects = [[], Ctor, new Ctor(), /foo/, new Date(), {}];
    const allObjects = nonThingObjects.concat([thingObject]);
    shouldValidate(assert, true, [
        [{}, allObjects],
        [Object, allObjects],
        ['object', allObjects],
        [{ thing: 'string' }, [thingObject]],
        [{ thing: function() {} }, allObjects],
    ]);
    shouldValidate(assert, false, [
        [{}, primitives],
        [Object, primitives],
        ['object', primitives],
        [{ thing: 'string' }, primitives.concat(nonThingObjects)],
        [{ thing: function() {
            throw new Error('Custom failure, as expected.');
        } }, primitives.concat(allObjects)],
    ]);
});

QUnit.test('validates recursive objects', function(assert) {
    const mockObject = {
        sentinel: 'legit',
        thing: {
            sentinel: 'legit',
            thing: {},
        },
    };

    const descriptor = {
        sentinel: 'string',
        thing: validator.nullable(validator.recurse(() => descriptor)),
    };

    const validateRecurse = validator(descriptor);

    // validator should throw because mockObject.thing.thing isn't an exact copy
    // of mockObject.thing
    assert.throws(function() {
        validateRecurse(mockObject);
    }, 'Failed to validate recursive object');

    // Reassign the deepest nested `thing` to null
    mockObject.thing.thing = null;

    // This should pass because `thing` is nullable
    try {
        validateRecurse(mockObject);
        assert.ok(true, 'Validates');
    } catch (e) {
        assert.ok(false, 'Failed to validate');
    }
});

QUnit.test('throws on bad validators', function(assert) {
    ['str?ing', 'bool?ean', 'nu?mber', 1, true].forEach(function(type) {
        assert.throws(function() {
            validator(type);
        });
    });
});

QUnit.test('strictEqual', function(assert) {
    const validateStrZero = validator.strictEqual('0');
    assert.ok(validateStrZero('0') || true, 'Validates strict equal.');
    [0, 1, '1'].forEach(function(value) {
        assert.throws(function() {
            validateStrZero(value);
        });
    });
});

QUnit.test('asEnum', function(assert) {
    const Enum = {
        one: 'one',
        two: 'two',
    };
    const validateEnum = validator.asEnum('Enum', Enum);
    Object.keys(Enum).forEach(function(key) {
        assert.ok(validateEnum(key) || true, 'Validates enum values.');
    });
    assert.throws(function() {
        validateEnum('three');
    }, 'throws when a value is not present');

    const mixedEnum = {
        ONE: 'one',
        two: 'TWO',
    };
    const validateMixedENum = validator.asEnum('mixedEnum', mixedEnum);
    Object.keys(mixedEnum).forEach(key => {
        const val = mixedEnum[key];
        assert.ok(validateMixedENum(val) || true, 'Validates the enum values, not the keys');
    });
});

QUnit.test('nullable', function(assert) {
    const desc = { one: 'string' };
    const validateNullableDesc = validator.nullable(desc);
    [null, { one: 'one' }].forEach(function(v) {
        try {
            validateNullableDesc(v);
            assert.ok(true, 'Validates ' + v);
        } catch (e) {
            assert.ok(false, 'Failed to validate ' + v);
        }
    });
    [0, false, {}].forEach(function(val) {
        assert.throws(function() {
            validateNullableDesc(val);
        });
    });
});

/**
 * Any validation on the descriptor "desc" should throw with the given value that throws.
 *
 * You can pass an optional errorProp in if the errors occurs on a nested error.
 * @param {Function} assert - the QUnit assert fn
 * @param {Object} desc - The JSON validation descriptor
 * @param {string} propName - the property in the object we are validating
 * @param {*} valThatThrows - The value that will make the validator function throw an error
 * @param {string} errorProp - The name of the property that should be contained in the error message. Defaults to `propName`.
 */
function shouldThrowFn(assert, desc, propName, valThatThrows, errorProp = propName) {
    assert.throws(
        function() {
            const throwingValidator = validator(desc);
            throwingValidator(
                _.isArray(desc) ? // if the descriptor is an array then the val passed in should be an array of things
                    valThatThrows :
                    // otherwise we'll assume an object
                    { [propName]: valThatThrows }
            );
        },
        new RegExp(errorProp),
        `Throws for descriptor ${JSON.stringify(desc)} when the value ${JSON.stringify(valThatThrows)} is passed.`
    );
}

QUnit.test('includes field names in errors', function(assert) {
    const shouldThrow = shouldThrowFn.bind(null, assert);

    const stringDescriptor = { stringProp: 'string' };
    // throws with a prop name in the error for invalid string props
    shouldThrow(stringDescriptor, 'stringProp', 123);
    shouldThrow(stringDescriptor, 'stringProp', null);
    shouldThrow(stringDescriptor, 'stringProp', []);

    const arrayDescriptor = { arrayProp: [] };
    // throws with a prop name in the error for invalid array props
    shouldThrow(arrayDescriptor, 'arrayProp', '');
    shouldThrow(arrayDescriptor, 'arrayProp', 123);
    shouldThrow(arrayDescriptor, 'arrayProp', null);

    const nameModel = {
        first: 'string',
        last: 'string',
    };
    const userModel = {
        name: nameModel,
        age: 'number?',
    };
    const validUser = {
        name: {
            first: 'John',
            last: 'Smith',
        },
        age: 25,
    };

    const simpleArrayDescriptor = [userModel];
    // Simple array descriptors should show a path starting with the index of the item that failed validation
    shouldThrow(simpleArrayDescriptor, 0, [validUser, { name: 123 }], '1.name');

    const nestedArrayDescriptor = { arrayProp: [userModel] };
    // Nested array things should include the full path to the property
    shouldThrow(nestedArrayDescriptor, 'arrayProp', ['not a user']);
    shouldThrow(nestedArrayDescriptor, 'arrayProp', [validUser, 'not a user'], 'arrayProp.1');
    shouldThrow(nestedArrayDescriptor, 'arrayProp', [validUser, { name: 123 }], 'arrayProp.1.name');
    shouldThrow(nestedArrayDescriptor, 'arrayProp', [validUser, { name: { first: 'Foo' } }], 'arrayProp.1.name.last');

    const objDescriptor = { objProp: userModel };
    // It will throw on nested object properties and provide a full path to the error
    shouldThrow(objDescriptor, 'objProp', '');
    shouldThrow(objDescriptor, 'objProp', 123);
    shouldThrow(objDescriptor, 'objProp', { name: 123 }, 'objProp.name');
    shouldThrow(objDescriptor, 'objProp', { name: { last: 'Bar' } }, 'objProp.name.first');

    const invalidDescriptor = { objProp: { invalidProp: 123 } };
    // When we validate a nested descriptor we also expect the correct namepath
    shouldThrow(invalidDescriptor, 'invalidProp');
    shouldThrow(invalidDescriptor, 'myProp', { name: 123 }, 'objProp.invalidProp');
});
