'use strict';

// Load modules

const Hoek = require('hoek');
const Settings = require('./settings');
const Ref = require('../../ref');
const Errors = require('../../errors');
const State = require('../state');
const Symbols = require('../symbols');

// Delay-loaded to prevent circular dependencies
let Alternatives = null;
let Cast = null;
let Schemas = null;


// Declare internals

const internals = {
    Set: require('../../set')
};


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

    // context: null
};


module.exports = internals.Any = class {

    constructor() {

        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,
             normalize: undefined,                   // NFC, NFD, NFKC, NFKD
             case: undefined,                        // upper, lower
             empty: undefined,
             func: false,
             raw: false
             */
        };

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

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

    _init() {

        return this;
    }

    get schemaType() {

        return this._type;
    }

    createError(type, context, state, options, flags = this._flags) {

        return Errors.create(type, context, state, options, flags);
    }

    createOverrideError(type, context, state, options, message, template) {

        return Errors.create(type, context, state, options, this._flags, message, template);
    }

    checkOptions(options) {

        Schemas = Schemas || require('../../schemas');

        const result = Schemas.options.validate(options);

        if (result.error) {
            throw new Error(result.error.details[0].message);
        }
    }

    clone() {

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

        obj.isJoi = true;
        obj._currentJoi = this._currentJoi;
        obj._type = this._type;
        obj._settings = this._settings;
        obj._baseType = this._baseType;
        obj._valids = this._valids.slice();
        obj._invalids = this._invalids.slice();
        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;
    }

    concat(schema) {

        Hoek.assert(schema instanceof internals.Any, '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 ? Settings.concat(obj._settings, schema._settings) : schema._settings;
        obj._valids.merge(schema._valids, schema._invalids);
        obj._invalids.merge(schema._invalids, schema._valids);
        obj._tests.push(...schema._tests);
        obj._refs.push(...schema._refs);
        if (obj._flags.empty && schema._flags.empty) {
            obj._flags.empty = obj._flags.empty.concat(schema._flags.empty);
            const flags = Object.assign({}, schema._flags);
            delete flags.empty;
            Hoek.merge(obj._flags, flags);
        }
        else if (schema._flags.empty) {
            obj._flags.empty = schema._flags.empty;
            const flags = Object.assign({}, schema._flags);
            delete flags.empty;
            Hoek.merge(obj._flags, flags);
        }
        else {
            Hoek.merge(obj._flags, schema._flags);
        }

        obj._description = schema._description || obj._description;
        obj._unit = schema._unit || obj._unit;
        obj._notes.push(...schema._notes);
        obj._tags.push(...schema._tags);
        obj._examples.push(...schema._examples);
        obj._meta.push(...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;
    }

    _test(name, arg, func, options) {

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

    _testUnique(name, arg, func, options) {

        const obj = this.clone();
        obj._tests = obj._tests.filter((test) => test.name !== name);
        obj._tests.push({ func, name, arg, options });
        return obj;
    }

    options(options) {

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

        const obj = this.clone();
        obj._settings = Settings.concat(obj._settings, options);
        return obj;
    }

    strict(isStrict) {

        const obj = this.clone();

        const convert = isStrict === undefined ? false : !isStrict;
        obj._settings = Settings.concat(obj._settings, { convert });
        return obj;
    }

    raw(isRaw) {

        const value = isRaw === undefined ? true : isRaw;

        if (this._flags.raw === value) {
            return this;
        }

        const obj = this.clone();
        obj._flags.raw = value;
        return obj;
    }

    error(err) {

        Hoek.assert(err && (err instanceof Error || typeof err === 'function'), 'Must provide a valid Error object or a function');

        const obj = this.clone();
        obj._flags.error = err;
        return obj;
    }

    allow(...values) {

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

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

        return obj;
    }

    valid(...values) {

        const obj = this.allow(...values);
        obj._flags.allowOnly = true;
        return obj;
    }

    invalid(...values) {

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

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

        return obj;
    }

    required() {

        if (this._flags.presence === 'required') {
            return this;
        }

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

    optional() {

        if (this._flags.presence === 'optional') {
            return this;
        }

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


    forbidden() {

        if (this._flags.presence === 'forbidden') {
            return this;
        }

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


    strip() {

        if (this._flags.strip) {
            return this;
        }

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

    applyFunctionToChildren(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](...args);
    }

    default(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;
    }

    empty(schema) {

        const obj = this.clone();
        if (schema === undefined) {
            delete obj._flags.empty;
        }
        else {
            obj._flags.empty = Cast.schema(this._currentJoi, schema);
        }

        return obj;
    }

    when(condition, 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(this._currentJoi, options.then)) : undefined;
        const otherwise = options.hasOwnProperty('otherwise') ? this.concat(Cast.schema(this._currentJoi, options.otherwise)) : undefined;

        Alternatives = Alternatives || require('../alternatives');

        const alternativeOptions = { then, otherwise };
        if (Object.prototype.hasOwnProperty.call(options, 'is')) {
            alternativeOptions.is = options.is;
        }

        const obj = Alternatives.when(condition, alternativeOptions);
        obj._flags.presence = 'ignore';
        obj._baseType = this;

        return obj;
    }

    description(desc) {

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

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

    notes(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;
    }

    tags(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;
    }

    meta(meta) {

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

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

    example(...examples) {

        Hoek.assert(examples.length > 0, 'Missing examples');

        const processed = [];
        for (let i = 0; i < examples.length; ++i) {
            const example = [].concat(examples[i]);
            Hoek.assert(example.length <= 2, `Bad example format at index ${i}`);

            const value = example[0];
            let options = example[1];
            if (options !== undefined) {
                Hoek.assert(options && typeof options === 'object', `Options for example at index ${i} must be an object`);
                const unknownOptions = Object.keys(options).filter((option) => !['parent', 'context'].includes(option));
                Hoek.assert(unknownOptions.length === 0, `Unknown example options ${unknownOptions} at index ${i}`);
            }
            else {
                options = {};
            }

            const localState = new State('', [], options.parent || null);
            const result = this._validate(value, localState, Settings.concat(internals.defaults, options.context ? { context: options.context } : null));
            Hoek.assert(!result.errors, `Bad example at index ${i}:`, result.errors && Errors.process(result.errors, value));

            const ex = { value };
            if (Object.keys(options).length) {
                ex.options = options;
            }

            processed.push(ex);
        }

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

    unit(name) {

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

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

    _prepareEmptyValue(value) {

        if (typeof value === 'string' && this._flags.trim) {
            return value.trim();
        }

        return value;
    }

    _validate(value, state, options, reference) {

        const originalValue = value;

        // Setup state and settings

        state = state || new State('', [], null, reference);

        if (this._settings) {
            const isDefaultOptions = options === internals.defaults;
            if (isDefaultOptions && this._settings[Symbols.settingsCache]) {
                options = this._settings[Symbols.settingsCache];
            }
            else {
                options = Settings.concat(options, this._settings);
                if (isDefaultOptions) {
                    this._settings[Symbols.settingsCache] = options;
                }
            }
        }

        let errors = [];

        if (this._coerce) {
            const coerced = this._coerce(value, state, options);
            if (coerced.errors) {
                value = coerced.value;
                errors = errors.concat(coerced.errors);
                return this._finalizeValue(value, originalValue, errors, state, options);                            // Coerced error always aborts early
            }

            value = coerced.value;
        }

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

        // 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 this._finalizeValue(value, originalValue, errors, state, options);
                }
            }
        }
        else if (presence === 'required' &&
            value === undefined) {

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

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

        // Check allowed and denied values using the original value

        let match = this._valids.get(value, state, options, this._flags.insensitive);
        if (match) {
            if (options.convert) {
                value = match.value;
            }

            return this._finalizeValue(value, originalValue, errors, state, options);
        }

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

                return this._finalizeValue(value, originalValue, errors, state, options);
            }
        }

        // Convert value and validate type

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

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

                // Check allowed and denied values using the converted value

                match = this._valids.get(value, state, options, this._flags.insensitive);
                if (match) {
                    value = match.value;
                    return this._finalizeValue(value, originalValue, errors, state, options);
                }

                if (this._invalids.has(value, state, options, this._flags.insensitive)) {
                    errors.push(this.createError(value === '' ? 'any.empty' : 'any.invalid', { value, invalids: this._invalids.values({ stripUndefined: true }) }, state, options));
                    if (options.abortEarly) {
                        return this._finalizeValue(value, originalValue, errors, state, options);
                    }
                }
            }
        }

        // Required values did not match

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

        // Validate tests

        for (let i = 0; i < this._tests.length; ++i) {
            const test = this._tests[i];
            const ret = test.func.call(this, value, state, options);
            if (ret instanceof Errors.Err) {
                errors.push(ret);
                if (options.abortEarly) {
                    return this._finalizeValue(value, originalValue, errors, state, options);
                }
            }
            else {
                value = ret;
            }
        }

        return this._finalizeValue(value, originalValue, errors, state, options);
    }

    _finalizeValue(value, originalValue, errors, state, options) {

        let finalValue;

        if (value !== undefined) {
            finalValue = this._flags.raw ? originalValue : value;
        }
        else if (options.noDefaults) {
            finalValue = value;
        }
        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 args;

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

                args = [Hoek.clone(state.parent), options];
            }

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

        if (errors.length && typeof this._flags.error === 'function') {
            const change = this._flags.error.call(this, errors);

            if (typeof change === 'string') {
                errors = [this.createOverrideError('override', { reason: errors }, state, options, change)];
            }
            else {
                errors = [].concat(change)
                    .map((err) => {

                        return err instanceof Error ?
                            err :
                            this.createOverrideError(err.type || 'override', err.context, state, options, err.message, err.template);
                    });
            }
        }

        return {
            value: this._flags.strip ? undefined : finalValue,
            finalValue,
            errors: errors.length ? errors : null
        };
    }

    _validateWithOptions(value, options, callback) {

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

        const settings = Settings.concat(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,
            then(resolve, reject) {

                if (errors) {
                    return Promise.reject(errors).catch(reject);
                }

                return Promise.resolve(result.value).then(resolve);
            },
            catch(reject) {

                if (errors) {
                    return Promise.reject(errors).catch(reject);
                }

                return Promise.resolve(result.value);
            }
        };
    }

    validate(value, options, callback) {

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

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

    describe() {

        const description = {
            type: this._type
        };

        const flags = Object.keys(this._flags);
        if (flags.length) {
            if (['empty', 'default', 'lazy', 'label'].some((flag) => this._flags.hasOwnProperty(flag))) {
                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] = {
                                description: this._flags[flag].description,
                                function   : this._flags[flag]
                            };
                        }
                        else {
                            description.flags[flag] = this._flags[flag];
                        }
                    }
                    else if (flag === 'lazy' || flag === 'label') {
                        // We don't want it in the description
                    }
                    else {
                        description.flags[flag] = this._flags[flag];
                    }
                }
            }
            else {
                description.flags = this._flags;
            }
        }

        if (this._settings) {
            description.options = Hoek.clone(this._settings);
        }

        if (this._baseType) {
            description.base = this._baseType.describe();
        }

        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;
            }

            const options = validator.options;
            if (options) {
                if (options.hasRef) {
                    item.arg = {};
                    const keys = Object.keys(validator.arg);
                    for (let j = 0; j < keys.length; ++j) {
                        const key = keys[j];
                        const value = validator.arg[key];
                        item.arg[key] = Ref.isRef(value) ? value.toString() : value;
                    }
                }

                if (typeof options.description === 'string') {
                    item.description = options.description;
                }
                else if (typeof options.description === 'function') {
                    item.description = options.description(item.arg);
                }
            }

            description.rules.push(item);
        }

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

        const label = this._getLabel();
        if (label) {
            description.label = label;
        }

        return description;
    }

    label(name) {

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

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

    _getLabel(def) {

        return this._flags.label || def;
    }

};


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

// Aliases

internals.Any.prototype.only = internals.Any.prototype.equal = internals.Any.prototype.valid;
internals.Any.prototype.disallow = internals.Any.prototype.not = internals.Any.prototype.invalid;
internals.Any.prototype.exist = internals.Any.prototype.required;


internals._try = function (fn, args = []) {

    let err;
    let result;

    try {
        result = fn(...args);
    }
    catch (e) {
        err = e;
    }

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