'use strict';

// Load modules

const Hoek = require('hoek');
const Ref = require('./ref');
const Errors = require('./errors');
let Alternatives = null;                // Delay-loaded to prevent circular dependencies
let Cast = null;


// Declare internals

const internals = {};


internals.defaults = {
    abortEarly: true,
    convert: true,
    allowUnknown: false,
    skipFunctions: false,
    stripUnknown: false,
    language: {},
    presence: 'optional',
    raw: false,
    strip: false,
    noDefaults: false

    // context: null
};


internals.checkOptions = function (options) {

    const Schemas = require('./schemas');
    const result = Schemas.options.validate(options);
    if (result.error) {
        throw new Error(result.error.details[0].message);
    }
};


module.exports = internals.Any = function () {

    Cast = Cast || require('./cast');

    this.isJoi = true;
    this._type = 'any';
    this._settings = null;
    this._valids = new internals.Set();
    this._invalids = new internals.Set();
    this._tests = [];
    this._refs = [];
    this._flags = {
    /*
        presence: 'optional',                   // optional, required, forbidden, ignore
        allowOnly: false,
        allowUnknown: undefined,
        default: undefined,
        forbidden: false,
        encoding: undefined,
        insensitive: false,
        trim: false,
        case: undefined,                        // upper, lower
        empty: undefined,
        func: false
    */
    };

    this._description = null;
    this._unit = null;
    this._notes = [];
    this._tags = [];
    this._examples = [];
    this._meta = [];

    this._inner = {};                           // Hash of arrays of immutable objects
};


internals.Any.prototype.createError = Errors.create;


internals.Any.prototype.isImmutable = true;     // Prevents Hoek from deep cloning schema objects


internals.Any.prototype.clone = function () {

    const obj = Object.create(Object.getPrototypeOf(this));

    obj.isJoi = true;
    obj._type = this._type;
    obj._settings = internals.concatSettings(this._settings);
    obj._valids = Hoek.clone(this._valids);
    obj._invalids = Hoek.clone(this._invalids);
    obj._tests = this._tests.slice();
    obj._refs = this._refs.slice();
    obj._flags = Hoek.clone(this._flags);

    obj._description = this._description;
    obj._unit = this._unit;
    obj._notes = this._notes.slice();
    obj._tags = this._tags.slice();
    obj._examples = this._examples.slice();
    obj._meta = this._meta.slice();

    obj._inner = {};
    const inners = Object.keys(this._inner);
    for (let i = 0; i < inners.length; ++i) {
        const key = inners[i];
        obj._inner[key] = this._inner[key] ? this._inner[key].slice() : null;
    }

    return obj;
};


internals.Any.prototype.concat = function (schema) {

    Hoek.assert(schema && schema.isJoi, 'Invalid schema object');
    Hoek.assert(this._type === 'any' || schema._type === 'any' || schema._type === this._type, 'Cannot merge type', this._type, 'with another type:', schema._type);

    let obj = this.clone();

    if (this._type === 'any' && schema._type !== 'any') {

        // Reset values as if we were "this"
        const tmpObj = schema.clone();
        const keysToRestore = ['_settings', '_valids', '_invalids', '_tests', '_refs', '_flags', '_description', '_unit',
            '_notes', '_tags', '_examples', '_meta', '_inner'];

        for (let i = 0; i < keysToRestore.length; ++i) {
            tmpObj[keysToRestore[i]] = obj[keysToRestore[i]];
        }

        obj = tmpObj;
    }

    obj._settings = obj._settings ? internals.concatSettings(obj._settings, schema._settings) : schema._settings;
    obj._valids.merge(schema._valids, schema._invalids);
    obj._invalids.merge(schema._invalids, schema._valids);
    obj._tests = obj._tests.concat(schema._tests);
    obj._refs = obj._refs.concat(schema._refs);
    Hoek.merge(obj._flags, schema._flags);

    obj._description = schema._description || obj._description;
    obj._unit = schema._unit || obj._unit;
    obj._notes = obj._notes.concat(schema._notes);
    obj._tags = obj._tags.concat(schema._tags);
    obj._examples = obj._examples.concat(schema._examples);
    obj._meta = obj._meta.concat(schema._meta);

    const inners = Object.keys(schema._inner);
    const isObject = obj._type === 'object';
    for (let i = 0; i < inners.length; ++i) {
        const key = inners[i];
        const source = schema._inner[key];
        if (source) {
            const target = obj._inner[key];
            if (target) {
                if (isObject && key === 'children') {
                    const keys = {};

                    for (let j = 0; j < target.length; ++j) {
                        keys[target[j].key] = j;
                    }

                    for (let j = 0; j < source.length; ++j) {
                        const sourceKey = source[j].key;
                        if (keys[sourceKey] >= 0) {
                            target[keys[sourceKey]] = {
                                key: sourceKey,
                                schema: target[keys[sourceKey]].schema.concat(source[j].schema)
                            };
                        }
                        else {
                            target.push(source[j]);
                        }
                    }
                }
                else {
                    obj._inner[key] = obj._inner[key].concat(source);
                }
            }
            else {
                obj._inner[key] = source.slice();
            }
        }
    }

    return obj;
};


internals.Any.prototype._test = function (name, arg, func) {

    const obj = this.clone();
    obj._tests.push({ func, name, arg });
    return obj;
};


internals.Any.prototype.options = function (options) {

    Hoek.assert(!options.context, 'Cannot override context');
    internals.checkOptions(options);

    const obj = this.clone();
    obj._settings = internals.concatSettings(obj._settings, options);
    return obj;
};


internals.Any.prototype.strict = function (isStrict) {

    const obj = this.clone();
    obj._settings = obj._settings || {};
    obj._settings.convert = isStrict === undefined ? false : !isStrict;
    return obj;
};


internals.Any.prototype.raw = function (isRaw) {

    const obj = this.clone();
    obj._settings = obj._settings || {};
    obj._settings.raw = isRaw === undefined ? true : isRaw;
    return obj;
};


internals.Any.prototype.error = function (err) {

    Hoek.assert(err && err instanceof Error, 'Must provide a valid Error object');

    const obj = this.clone();
    obj._settings = obj._settings || {};
    obj._settings.error = err;
    return obj;
};


internals.Any.prototype._allow = function () {

    const values = Hoek.flatten(Array.prototype.slice.call(arguments));
    for (let i = 0; i < values.length; ++i) {
        const value = values[i];

        Hoek.assert(value !== undefined, 'Cannot call allow/valid/invalid with undefined');
        this._invalids.remove(value);
        this._valids.add(value, this._refs);
    }
};


internals.Any.prototype.allow = function () {

    const obj = this.clone();
    obj._allow.apply(obj, arguments);
    return obj;
};


internals.Any.prototype.valid = internals.Any.prototype.only = internals.Any.prototype.equal = function () {

    const obj = this.allow.apply(this, arguments);
    obj._flags.allowOnly = true;
    return obj;
};


internals.Any.prototype.invalid = internals.Any.prototype.disallow = internals.Any.prototype.not = function (value) {

    const obj = this.clone();
    const values = Hoek.flatten(Array.prototype.slice.call(arguments));
    for (let i = 0; i < values.length; ++i) {
        value = values[i];

        Hoek.assert(value !== undefined, 'Cannot call allow/valid/invalid with undefined');
        obj._valids.remove(value);
        obj._invalids.add(value, this._refs);
    }

    return obj;
};


internals.Any.prototype.required = internals.Any.prototype.exist = function () {

    const obj = this.clone();
    obj._flags.presence = 'required';
    return obj;
};


internals.Any.prototype.optional = function () {

    const obj = this.clone();
    obj._flags.presence = 'optional';
    return obj;
};


internals.Any.prototype.forbidden = function () {

    const obj = this.clone();
    obj._flags.presence = 'forbidden';
    return obj;
};


internals.Any.prototype.strip = function () {

    const obj = this.clone();
    obj._flags.strip = true;
    return obj;
};


internals.Any.prototype.applyFunctionToChildren = function (children, fn, args, root) {

    children = [].concat(children);

    if (children.length !== 1 || children[0] !== '') {
        root = root ? (root + '.') : '';

        const extraChildren = (children[0] === '' ? children.slice(1) : children).map((child) => {

            return root + child;
        });

        throw new Error('unknown key(s) ' + extraChildren.join(', '));
    }

    return this[fn].apply(this, args);
};


internals.Any.prototype.default = function (value, description) {

    if (typeof value === 'function' &&
        !Ref.isRef(value)) {

        if (!value.description &&
            description) {

            value.description = description;
        }

        if (!this._flags.func) {
            Hoek.assert(typeof value.description === 'string' && value.description.length > 0, 'description must be provided when default value is a function');
        }
    }

    const obj = this.clone();
    obj._flags.default = value;
    Ref.push(obj._refs, value);
    return obj;
};


internals.Any.prototype.empty = function (schema) {

    const obj = this.clone();
    obj._flags.empty = schema === undefined ? undefined : Cast.schema(schema);
    return obj;
};


internals.Any.prototype.when = function (ref, options) {

    Hoek.assert(options && typeof options === 'object', 'Invalid options');
    Hoek.assert(options.then !== undefined || options.otherwise !== undefined, 'options must have at least one of "then" or "otherwise"');

    const then = options.hasOwnProperty('then') ? this.concat(Cast.schema(options.then)) : undefined;
    const otherwise = options.hasOwnProperty('otherwise') ? this.concat(Cast.schema(options.otherwise)) : undefined;

    Alternatives = Alternatives || require('./alternatives');
    const obj = Alternatives.when(ref, { is: options.is, then, otherwise });
    obj._flags.presence = 'ignore';
    obj._settings = internals.concatSettings(obj._settings, { baseType: this });

    return obj;
};


internals.Any.prototype.description = function (desc) {

    Hoek.assert(desc && typeof desc === 'string', 'Description must be a non-empty string');

    const obj = this.clone();
    obj._description = desc;
    return obj;
};


internals.Any.prototype.notes = function (notes) {

    Hoek.assert(notes && (typeof notes === 'string' || Array.isArray(notes)), 'Notes must be a non-empty string or array');

    const obj = this.clone();
    obj._notes = obj._notes.concat(notes);
    return obj;
};


internals.Any.prototype.tags = function (tags) {

    Hoek.assert(tags && (typeof tags === 'string' || Array.isArray(tags)), 'Tags must be a non-empty string or array');

    const obj = this.clone();
    obj._tags = obj._tags.concat(tags);
    return obj;
};

internals.Any.prototype.meta = function (meta) {

    Hoek.assert(meta !== undefined, 'Meta cannot be undefined');

    const obj = this.clone();
    obj._meta = obj._meta.concat(meta);
    return obj;
};


internals.Any.prototype.example = function (value) {

    Hoek.assert(arguments.length, 'Missing example');
    const result = this._validate(value, null, internals.defaults);
    Hoek.assert(!result.errors, 'Bad example:', result.errors && Errors.process(result.errors, value));

    const obj = this.clone();
    obj._examples.push(value);
    return obj;
};


internals.Any.prototype.unit = function (name) {

    Hoek.assert(name && typeof name === 'string', 'Unit name must be a non-empty string');

    const obj = this.clone();
    obj._unit = name;
    return obj;
};


internals._try = function (fn, arg) {

    let err;
    let result;

    try {
        result = fn.call(null, arg);
    }
    catch (e) {
        err = e;
    }

    return {
        value: result,
        error: err
    };
};


internals.Any.prototype._validate = function (value, state, options, reference) {

    const originalValue = value;

    // Setup state and settings

    state = state || { key: '', path: '', parent: null, reference };

    if (this._settings) {
        options = internals.concatSettings(options, this._settings);
    }

    let errors = [];
    const finish = () => {

        let finalValue;

        if (!this._flags.strip) {
            if (value !== undefined) {
                finalValue = options.raw ? originalValue : value;
            }
            else if (options.noDefaults) {
                finalValue = originalValue;
            }
            else if (Ref.isRef(this._flags.default)) {
                finalValue = this._flags.default(state.parent, options);
            }
            else if (typeof this._flags.default === 'function' &&
                    !(this._flags.func && !this._flags.default.description)) {

                let arg;

                if (state.parent !== null &&
                    this._flags.default.length > 0) {

                    arg = Hoek.clone(state.parent);
                }

                const defaultValue = internals._try(this._flags.default, arg);
                finalValue = defaultValue.value;
                if (defaultValue.error) {
                    errors.push(this.createError('any.default', defaultValue.error, state, options));
                }
            }
            else {
                finalValue = Hoek.clone(this._flags.default);
            }
        }

        return {
            value: finalValue,
            errors: errors.length ? errors : null
        };
    };

    // Check presence requirements

    const presence = this._flags.presence || options.presence;
    if (presence === 'optional') {
        if (value === undefined) {
            const isDeepDefault = this._flags.hasOwnProperty('default') && this._flags.default === undefined;
            if (isDeepDefault && this._type === 'object') {
                value = {};
            }
            else {
                return finish();
            }
        }
    }
    else if (presence === 'required' &&
            value === undefined) {

        errors.push(this.createError('any.required', null, state, options));
        return finish();
    }
    else if (presence === 'forbidden') {
        if (value === undefined) {
            return finish();
        }

        errors.push(this.createError('any.unknown', null, state, options));
        return finish();
    }

    if (this._flags.empty && !this._flags.empty._validate(value, null, internals.defaults).errors) {
        value = undefined;
        return finish();
    }

    // Check allowed and denied values using the original value

    if (this._valids.has(value, state, options, this._flags.insensitive)) {
        return finish();
    }

    if (this._invalids.has(value, state, options, this._flags.insensitive)) {
        errors.push(this.createError(value === '' ? 'any.empty' : 'any.invalid', null, state, options));
        if (options.abortEarly ||
            value === undefined) {          // No reason to keep validating missing value

            return finish();
        }
    }

    // Convert value and validate type

    if (this._base) {
        const base = this._base.call(this, value, state, options);
        if (base.errors) {
            value = base.value;
            errors = errors.concat(base.errors);
            return finish();                            // Base error always aborts early
        }

        if (base.value !== value) {
            value = base.value;

            // Check allowed and denied values using the converted value

            if (this._valids.has(value, state, options, this._flags.insensitive)) {
                return finish();
            }

            if (this._invalids.has(value, state, options, this._flags.insensitive)) {
                errors.push(this.createError(value === '' ? 'any.empty' : 'any.invalid', null, state, options));
                if (options.abortEarly) {
                    return finish();
                }
            }
        }
    }

    // Required values did not match

    if (this._flags.allowOnly) {
        errors.push(this.createError('any.allowOnly', { valids: this._valids.values({ stripUndefined: true }) }, state, options));
        if (options.abortEarly) {
            return finish();
        }
    }

    // Helper.validate tests

    for (let i = 0; i < this._tests.length; ++i) {
        const test = this._tests[i];
        const err = test.func.call(this, value, state, options);
        if (err) {
            errors.push(err);
            if (options.abortEarly) {
                return finish();
            }
        }
    }

    return finish();
};


internals.Any.prototype._validateWithOptions = function (value, options, callback) {

    if (options) {
        internals.checkOptions(options);
    }

    const settings = internals.concatSettings(internals.defaults, options);
    const result = this._validate(value, null, settings);
    const errors = Errors.process(result.errors, value);

    if (callback) {
        return callback(errors, result.value);
    }

    return { error: errors, value: result.value };
};


internals.Any.prototype.validate = function (value, options, callback) {

    if (typeof options === 'function') {
        return this._validateWithOptions(value, null, options);
    }

    return this._validateWithOptions(value, options, callback);
};


internals.Any.prototype.describe = function () {

    const description = {
        type: this._type
    };

    const flags = Object.keys(this._flags);
    if (flags.length) {
        if (this._flags.hasOwnProperty('empty') || this._flags.hasOwnProperty('default') || this._flags.lazy) {
            description.flags = {};
            for (let i = 0; i < flags.length; ++i) {
                const flag = flags[i];
                if (flag === 'empty') {
                    description.flags[flag] = this._flags[flag].describe();
                }
                else if (flag === 'default') {
                    if (Ref.isRef(this._flags[flag])) {
                        description.flags[flag] = this._flags[flag].toString();
                    }
                    else if (typeof this._flags[flag] === 'function') {
                        description.flags[flag] = this._flags[flag].description;
                    }
                    else {
                        description.flags[flag] = this._flags[flag];
                    }
                }
                else if (flag === 'lazy') {
                    // We don't want it in the description
                }
                else {
                    description.flags[flag] = this._flags[flag];
                }
            }
        }
        else {
            description.flags = this._flags;
        }
    }

    if (this._description) {
        description.description = this._description;
    }

    if (this._notes.length) {
        description.notes = this._notes;
    }

    if (this._tags.length) {
        description.tags = this._tags;
    }

    if (this._meta.length) {
        description.meta = this._meta;
    }

    if (this._examples.length) {
        description.examples = this._examples;
    }

    if (this._unit) {
        description.unit = this._unit;
    }

    const valids = this._valids.values();
    if (valids.length) {
        description.valids = valids.map((v) => {

            return Ref.isRef(v) ? v.toString() : v;
        });
    }

    const invalids = this._invalids.values();
    if (invalids.length) {
        description.invalids = invalids.map((v) => {

            return Ref.isRef(v) ? v.toString() : v;
        });
    }

    description.rules = [];

    for (let i = 0; i < this._tests.length; ++i) {
        const validator = this._tests[i];
        const item = { name: validator.name };
        if (validator.arg !== void 0) {
            item.arg = Ref.isRef(validator.arg) ? validator.arg.toString() : validator.arg;
        }
        description.rules.push(item);
    }

    if (!description.rules.length) {
        delete description.rules;
    }

    const label = Hoek.reach(this._settings, 'language.label');
    if (label) {
        description.label = label;
    }

    return description;
};


internals.Any.prototype.label = function (name) {

    Hoek.assert(name && typeof name === 'string', 'Label name must be a non-empty string');

    const obj = this.clone();
    const options = { language: { label: name } };

    obj._settings = internals.concatSettings(obj._settings, options);
    return obj;
};


// Set

internals.Set = function () {

    this._set = [];
};


internals.Set.prototype.add = function (value, refs) {

    if (!Ref.isRef(value) && this.has(value, null, null, false)) {

        return;
    }

    if (refs !== undefined) { // If it's a merge, we don't have any refs
        Ref.push(refs, value);
    }

    this._set.push(value);
};


internals.Set.prototype.merge = function (add, remove) {

    for (let i = 0; i < add._set.length; ++i) {
        this.add(add._set[i]);
    }

    for (let i = 0; i < remove._set.length; ++i) {
        this.remove(remove._set[i]);
    }
};


internals.Set.prototype.remove = function (value) {

    this._set = this._set.filter((item) => value !== item);
};


internals.Set.prototype.has = function (value, state, options, insensitive) {

    for (let i = 0; i < this._set.length; ++i) {
        let items = this._set[i];

        if (state && Ref.isRef(items)) { // Only resolve references if there is a state, otherwise it's a merge
            items = items(state.reference || state.parent, options);
        }

        if (!Array.isArray(items)) {
            items = [items];
        }

        for (let j = 0; j < items.length; ++j) {
            const item = items[j];
            if (typeof value !== typeof item) {
                continue;
            }

            if (value === item ||
                (value instanceof Date && item instanceof Date && value.getTime() === item.getTime()) ||
                (insensitive && typeof value === 'string' && value.toLowerCase() === item.toLowerCase()) ||
                (Buffer.isBuffer(value) && Buffer.isBuffer(item) && value.length === item.length && value.toString('binary') === item.toString('binary'))) {

                return true;
            }
        }
    }

    return false;
};


internals.Set.prototype.values = function (options) {

    if (options && options.stripUndefined) {
        const values = [];

        for (let i = 0; i < this._set.length; ++i) {
            const item = this._set[i];
            if (item !== undefined) {
                values.push(item);
            }
        }

        return values;
    }

    return this._set.slice();
};


internals.concatSettings = function (target, source) {

    // Used to avoid cloning context

    if (!target &&
        !source) {

        return null;
    }

    const obj = {};

    if (target) {
        Object.assign(obj, target);
    }

    if (source) {
        const sKeys = Object.keys(source);
        for (let i = 0; i < sKeys.length; ++i) {
            const key = sKeys[i];
            if (key !== 'language' ||
                !obj.hasOwnProperty(key)) {

                obj[key] = source[key];
            }
            else {
                obj[key] = Hoek.applyToDefaults(obj[key], source[key]);
            }
        }
    }

    return obj;
};
