'use strict';

const Bourne = require('@hapi/bourne');
const Hoek = require('@hapi/hoek');
const Topo = require('@hapi/topo');

const Any = require('../any');
const Errors = require('../../errors');
const Cast = require('../../cast');
const State = require('../state');


const internals = {};


internals.Object = class extends Any {

    constructor() {

        super();
        this._type = 'object';
        this._inner.children = null;
        this._inner.renames = [];
        this._inner.dependencies = [];
        this._inner.patterns = [];
    }

    _init(...args) {

        return args.length ? this.keys(...args) : this;
    }

    _base(value, state, options) {

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

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

        if (typeof value === 'string' &&
            options.convert) {

            if (value.length > 1 &&
                (value[0] === '{' || /^\s*\{/.test(value))) {

                try {
                    value = Bourne.parse(value);
                }
                catch (e) { }
            }
        }

        const type = this._flags.func ? 'function' : 'object';
        if (!value ||
            typeof value !== type ||
            Array.isArray(value)) {

            errors.push(this.createError(type + '.base', { value }, state, options));
            return finish();
        }

        // Skip if there are no other rules to test

        if (!this._inner.renames.length &&
            !this._inner.dependencies.length &&
            !this._inner.children &&                    // null allows any keys
            !this._inner.patterns.length) {

            target = value;
            return finish();
        }

        // Ensure target is a local copy (parsed) or shallow copy

        if (target === value) {
            if (type === 'object') {
                target = Object.create(Object.getPrototypeOf(value));
            }
            else {
                target = function (...args) {

                    return value.apply(this, args);
                };

                target.prototype = Hoek.clone(value.prototype);
            }

            const valueKeys = Object.keys(value);
            for (let i = 0; i < valueKeys.length; ++i) {
                target[valueKeys[i]] = value[valueKeys[i]];
            }
        }
        else {
            target = value;
        }

        // Rename keys

        const renamed = {};
        for (let i = 0; i < this._inner.renames.length; ++i) {
            const rename = this._inner.renames[i];

            if (rename.isRegExp) {
                const targetKeys = Object.keys(target);
                const matchedTargetKeys = [];

                for (let j = 0; j < targetKeys.length; ++j) {
                    if (rename.from.test(targetKeys[j])) {
                        matchedTargetKeys.push(targetKeys[j]);
                    }
                }

                const allUndefined = matchedTargetKeys.every((key) => target[key] === undefined);
                if (rename.options.ignoreUndefined && allUndefined) {
                    continue;
                }

                if (!rename.options.multiple &&
                    renamed[rename.to]) {

                    errors.push(this.createError('object.rename.regex.multiple', { from: matchedTargetKeys, to: rename.to }, state, options));
                    if (options.abortEarly) {
                        return finish();
                    }
                }

                if (Object.prototype.hasOwnProperty.call(target, rename.to) &&
                    !rename.options.override &&
                    !renamed[rename.to]) {

                    errors.push(this.createError('object.rename.regex.override', { from: matchedTargetKeys, to: rename.to }, state, options));
                    if (options.abortEarly) {
                        return finish();
                    }
                }

                if (allUndefined) {
                    delete target[rename.to];
                }
                else {
                    target[rename.to] = target[matchedTargetKeys[matchedTargetKeys.length - 1]];
                }

                renamed[rename.to] = true;

                if (!rename.options.alias) {
                    for (let j = 0; j < matchedTargetKeys.length; ++j) {
                        delete target[matchedTargetKeys[j]];
                    }
                }
            }
            else {
                if (rename.options.ignoreUndefined && target[rename.from] === undefined) {
                    continue;
                }

                if (!rename.options.multiple &&
                    renamed[rename.to]) {

                    errors.push(this.createError('object.rename.multiple', { from: rename.from, to: rename.to }, state, options));
                    if (options.abortEarly) {
                        return finish();
                    }
                }

                if (Object.prototype.hasOwnProperty.call(target, rename.to) &&
                    !rename.options.override &&
                    !renamed[rename.to]) {

                    errors.push(this.createError('object.rename.override', { from: rename.from, to: rename.to }, state, options));
                    if (options.abortEarly) {
                        return finish();
                    }
                }

                if (target[rename.from] === undefined) {
                    delete target[rename.to];
                }
                else {
                    target[rename.to] = target[rename.from];
                }

                renamed[rename.to] = true;

                if (!rename.options.alias) {
                    delete target[rename.from];
                }
            }
        }

        // Validate schema

        if (!this._inner.children &&            // null allows any keys
            !this._inner.patterns.length &&
            !this._inner.dependencies.length) {

            return finish();
        }

        const unprocessed = new Set(Object.keys(target));

        if (this._inner.children) {
            const stripProps = [];

            for (let i = 0; i < this._inner.children.length; ++i) {
                const child = this._inner.children[i];
                const key = child.key;
                const item = target[key];

                unprocessed.delete(key);

                const localState = new State(key, [...state.path, key], target, state.reference);
                const result = child.schema._validate(item, localState, options);
                if (result.errors) {
                    errors.push(this.createError('object.child', { key, child: child.schema._getLabel(key), reason: result.errors }, localState, options));

                    if (options.abortEarly) {
                        return finish();
                    }
                }
                else {
                    if (child.schema._flags.strip || (result.value === undefined && result.value !== item)) {
                        stripProps.push(key);
                        target[key] = result.finalValue;
                    }
                    else if (result.value !== undefined) {
                        target[key] = result.value;
                    }
                }
            }

            for (let i = 0; i < stripProps.length; ++i) {
                delete target[stripProps[i]];
            }
        }

        // Unknown keys

        if (unprocessed.size && this._inner.patterns.length) {

            for (const key of unprocessed) {
                const localState = new State(key, [...state.path, key], target, state.reference);
                const item = target[key];

                for (let i = 0; i < this._inner.patterns.length; ++i) {
                    const pattern = this._inner.patterns[i];

                    if (pattern.regex ?
                        pattern.regex.test(key) :
                        !pattern.schema._validate(key, state, { ...options, abortEarly:true }).errors) {

                        unprocessed.delete(key);

                        const result = pattern.rule._validate(item, localState, options);
                        if (result.errors) {
                            errors.push(this.createError('object.child', {
                                key,
                                child: pattern.rule._getLabel(key),
                                reason: result.errors
                            }, localState, options));

                            if (options.abortEarly) {
                                return finish();
                            }
                        }

                        target[key] = result.value;
                    }
                }
            }
        }

        if (unprocessed.size && (this._inner.children || this._inner.patterns.length)) {
            if ((options.stripUnknown && this._flags.allowUnknown !== true) ||
                options.skipFunctions) {

                const stripUnknown = options.stripUnknown
                    ? (options.stripUnknown === true ? true : !!options.stripUnknown.objects)
                    : false;


                for (const key of unprocessed) {
                    if (stripUnknown) {
                        delete target[key];
                        unprocessed.delete(key);
                    }
                    else if (typeof target[key] === 'function') {
                        unprocessed.delete(key);
                    }
                }
            }

            if ((this._flags.allowUnknown !== undefined ? !this._flags.allowUnknown : !options.allowUnknown)) {

                for (const unprocessedKey of unprocessed) {
                    errors.push(this.createError('object.allowUnknown', { child: unprocessedKey, value: target[unprocessedKey] }, {
                        key: unprocessedKey,
                        path: [...state.path, unprocessedKey]
                    }, options, {}));
                }
            }
        }

        // Validate dependencies

        for (let i = 0; i < this._inner.dependencies.length; ++i) {
            const dep = this._inner.dependencies[i];
            const hasKey = dep.key !== null;
            const splitKey = hasKey && dep.key.split('.');
            const localState = hasKey ? new State(splitKey[splitKey.length - 1], [...state.path, ...splitKey]) : new State(null, state.path);
            const err = internals[dep.type].call(this, dep.key, hasKey && Hoek.reach(target, dep.key, { functions: true }), dep.peers, target, localState, options);
            if (err instanceof Errors.Err) {
                errors.push(err);
                if (options.abortEarly) {
                    return finish();
                }
            }
        }

        return finish();
    }

    keys(schema) {

        Hoek.assert(schema === null || schema === undefined || typeof schema === 'object', 'Object schema must be a valid object');
        Hoek.assert(!schema || !(schema instanceof Any), 'Object schema cannot be a joi schema');

        const obj = this.clone();

        if (!schema) {
            obj._inner.children = null;
            return obj;
        }

        const children = Object.keys(schema);

        if (!children.length) {
            obj._inner.children = [];
            return obj;
        }

        const topo = new Topo();
        if (obj._inner.children) {
            for (let i = 0; i < obj._inner.children.length; ++i) {
                const child = obj._inner.children[i];

                // Only add the key if we are not going to replace it later
                if (!children.includes(child.key)) {
                    topo.add(child, { after: child._refs, group: child.key });
                }
            }
        }

        for (let i = 0; i < children.length; ++i) {
            const key = children[i];
            const child = schema[key];
            try {
                const cast = Cast.schema(this._currentJoi, child);
                topo.add({ key, schema: cast }, { after: cast._refs, group: key });
            }
            catch (castErr) {
                if (castErr.hasOwnProperty('path')) {
                    castErr.path = key + '.' + castErr.path;
                }
                else {
                    castErr.path = key;
                }

                throw castErr;
            }
        }

        obj._inner.children = topo.nodes;

        return obj;
    }

    append(schema) {
        // Skip any changes
        if (schema === null || schema === undefined || Object.keys(schema).length === 0) {
            return this;
        }

        return this.keys(schema);
    }

    unknown(allow) {

        const value = allow !== false;

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

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

    length(limit) {

        Hoek.assert(Number.isSafeInteger(limit) && limit >= 0, 'limit must be a positive integer');

        return this._test('length', limit, function (value, state, options) {

            if (Object.keys(value).length === limit) {
                return value;
            }

            return this.createError('object.length', { limit, value }, state, options);
        });
    }

    min(limit) {

        Hoek.assert(Number.isSafeInteger(limit) && limit >= 0, 'limit must be a positive integer');

        return this._test('min', limit, function (value, state, options) {

            if (Object.keys(value).length >= limit) {
                return value;
            }

            return this.createError('object.min', { limit, value }, state, options);
        });
    }

    max(limit) {

        Hoek.assert(Number.isSafeInteger(limit) && limit >= 0, 'limit must be a positive integer');

        return this._test('max', limit, function (value, state, options) {

            if (Object.keys(value).length <= limit) {
                return value;
            }

            return this.createError('object.max', { limit, value }, state, options);
        });
    }

    pattern(pattern, schema) {

        const isRegExp = pattern instanceof RegExp;
        Hoek.assert(isRegExp || pattern instanceof Any, 'pattern must be a regex or schema');
        Hoek.assert(schema !== undefined, 'Invalid rule');

        if (isRegExp) {
            Hoek.assert(!pattern.flags.includes('g') && !pattern.flags.includes('y'), 'pattern should not use global or sticky mode');
        }

        try {
            schema = Cast.schema(this._currentJoi, schema);
        }
        catch (castErr) {
            if (castErr.hasOwnProperty('path')) {
                castErr.message = `${castErr.message}(${castErr.path})`;
            }

            throw castErr;
        }

        const obj = this.clone();
        if (isRegExp) {
            obj._inner.patterns.push({ regex: pattern, rule: schema });
        }
        else {
            obj._inner.patterns.push({ schema: pattern, rule: schema });
        }

        return obj;
    }

    schema() {

        return this._test('schema', null, function (value, state, options) {

            if (value instanceof Any) {
                return value;
            }

            return this.createError('object.schema', null, state, options);
        });
    }

    with(key, peers) {

        Hoek.assert(arguments.length === 2, 'Invalid number of arguments, expected 2.');

        return this._dependency('with', key, peers);
    }

    without(key, peers) {

        Hoek.assert(arguments.length === 2, 'Invalid number of arguments, expected 2.');

        return this._dependency('without', key, peers);
    }

    xor(...peers) {

        peers = Hoek.flatten(peers);
        return this._dependency('xor', null, peers);
    }

    oxor(...peers) {

        return this._dependency('oxor', null, peers);
    }

    or(...peers) {

        peers = Hoek.flatten(peers);
        return this._dependency('or', null, peers);
    }

    and(...peers) {

        peers = Hoek.flatten(peers);
        return this._dependency('and', null, peers);
    }

    nand(...peers) {

        peers = Hoek.flatten(peers);
        return this._dependency('nand', null, peers);
    }

    requiredKeys(...children) {

        children = Hoek.flatten(children);
        return this.applyFunctionToChildren(children, 'required');
    }

    optionalKeys(...children) {

        children = Hoek.flatten(children);
        return this.applyFunctionToChildren(children, 'optional');
    }

    forbiddenKeys(...children) {

        children = Hoek.flatten(children);
        return this.applyFunctionToChildren(children, 'forbidden');
    }

    rename(from, to, options) {

        Hoek.assert(typeof from === 'string' || from instanceof RegExp, 'Rename missing the from argument');
        Hoek.assert(typeof to === 'string', 'Rename missing the to argument');
        Hoek.assert(to !== from, 'Cannot rename key to same name:', from);

        for (let i = 0; i < this._inner.renames.length; ++i) {
            Hoek.assert(this._inner.renames[i].from !== from, 'Cannot rename the same key multiple times');
        }

        const obj = this.clone();

        obj._inner.renames.push({
            from,
            to,
            options: Hoek.applyToDefaults(internals.renameDefaults, options || {}),
            isRegExp: from instanceof RegExp
        });

        return obj;
    }

    applyFunctionToChildren(children, fn, args = [], root) {

        children = [].concat(children);
        Hoek.assert(children.length > 0, 'expected at least one children');

        const groupedChildren = internals.groupChildren(children);
        let obj;

        if ('' in groupedChildren) {
            obj = this[fn](...args);
            delete groupedChildren[''];
        }
        else {
            obj = this.clone();
        }

        if (obj._inner.children) {
            root = root ? (root + '.') : '';

            for (let i = 0; i < obj._inner.children.length; ++i) {
                const child = obj._inner.children[i];
                const group = groupedChildren[child.key];

                if (group) {
                    obj._inner.children[i] = {
                        key: child.key,
                        _refs: child._refs,
                        schema: child.schema.applyFunctionToChildren(group, fn, args, root + child.key)
                    };

                    delete groupedChildren[child.key];
                }
            }
        }

        const remaining = Object.keys(groupedChildren);
        Hoek.assert(remaining.length === 0, 'unknown key(s)', remaining.join(', '));

        return obj;
    }

    _dependency(type, key, peers) {

        peers = [].concat(peers);
        for (let i = 0; i < peers.length; ++i) {
            Hoek.assert(typeof peers[i] === 'string', type, 'peers must be a string or array of strings');
        }

        const obj = this.clone();
        obj._inner.dependencies.push({ type, key, peers });
        return obj;
    }

    describe(shallow) {

        const description = super.describe();

        if (description.rules) {
            for (let i = 0; i < description.rules.length; ++i) {
                const rule = description.rules[i];
                // Coverage off for future-proof descriptions, only object().assert() is use right now
                if (/* $lab:coverage:off$ */rule.arg &&
                    typeof rule.arg === 'object' &&
                    rule.arg.schema &&
                    rule.arg.ref /* $lab:coverage:on$ */) {
                    rule.arg = {
                        schema: rule.arg.schema.describe(),
                        ref: rule.arg.ref.toString()
                    };
                }
            }
        }

        if (this._inner.children &&
            !shallow) {

            description.children = {};
            for (let i = 0; i < this._inner.children.length; ++i) {
                const child = this._inner.children[i];
                description.children[child.key] = child.schema.describe();
            }
        }

        if (this._inner.dependencies.length) {
            description.dependencies = Hoek.clone(this._inner.dependencies);
        }

        if (this._inner.patterns.length) {
            description.patterns = [];

            for (let i = 0; i < this._inner.patterns.length; ++i) {
                const pattern = this._inner.patterns[i];
                if (pattern.regex) {
                    description.patterns.push({ regex: pattern.regex.toString(), rule: pattern.rule.describe() });
                }
                else {
                    description.patterns.push({ schema: pattern.schema.describe(), rule: pattern.rule.describe() });
                }
            }
        }

        if (this._inner.renames.length > 0) {
            description.renames = Hoek.clone(this._inner.renames);
        }

        return description;
    }

    assert(ref, schema, message) {

        ref = Cast.ref(ref);
        Hoek.assert(ref.isContext || ref.depth > 1, 'Cannot use assertions for root level references - use direct key rules instead');
        message = message || 'pass the assertion test';
        Hoek.assert(typeof message === 'string', 'Message must be a string');

        try {
            schema = Cast.schema(this._currentJoi, schema);
        }
        catch (castErr) {
            if (castErr.hasOwnProperty('path')) {
                castErr.message = `${castErr.message}(${castErr.path})`;
            }

            throw castErr;
        }

        const key = ref.path[ref.path.length - 1];
        const path = ref.path.join('.');

        return this._test('assert', { schema, ref }, function (value, state, options) {

            const result = schema._validate(ref(value), null, options, value);
            if (!result.errors) {
                return value;
            }

            const localState = new State(key, ref.path, state.parent, state.reference);
            return this.createError('object.assert', { ref: path, message }, localState, options);
        });
    }

    type(constructor, name = constructor.name) {

        Hoek.assert(typeof constructor === 'function', 'type must be a constructor function');
        const typeData = {
            name,
            ctor: constructor
        };

        return this._test('type', typeData, function (value, state, options) {

            if (value instanceof constructor) {
                return value;
            }

            return this.createError('object.type', { type: typeData.name, value }, state, options);
        });
    }
};


internals.renameDefaults = {
    alias: false,                   // Keep old value in place
    multiple: false,                // Allow renaming multiple keys into the same target
    override: false                 // Overrides an existing key
};


internals.groupChildren = function (children) {

    children.sort();

    const grouped = {};

    for (let i = 0; i < children.length; ++i) {
        const child = children[i];
        Hoek.assert(typeof child === 'string', 'children must be strings');
        const group = child.split('.')[0];
        const childGroup = grouped[group] = (grouped[group] || []);
        childGroup.push(child.substring(group.length + 1));
    }

    return grouped;
};


internals.keysToLabels = function (schema, keys) {

    const children = schema._inner.children;

    if (!children) {
        return keys;
    }

    const findLabel = function (key) {

        const matchingChild = schema._currentJoi.reach(schema, key);
        return matchingChild ? matchingChild._getLabel(key) : key;
    };

    if (Array.isArray(keys)) {
        return keys.map(findLabel);
    }

    return findLabel(keys);
};


internals.with = function (key, value, peers, parent, state, options) {

    if (value === undefined) {
        return;
    }

    for (let i = 0; i < peers.length; ++i) {

        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist === undefined) {

            return this.createError('object.with', {
                main: key,
                mainWithLabel: internals.keysToLabels(this, key),
                peer,
                peerWithLabel: internals.keysToLabels(this, peer)
            }, state, options);
        }
    }
};


internals.without = function (key, value, peers, parent, state, options) {

    if (value === undefined) {
        return;
    }

    for (let i = 0; i < peers.length; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist !== undefined) {

            return this.createError('object.without', {
                main: key,
                mainWithLabel: internals.keysToLabels(this, key),
                peer,
                peerWithLabel: internals.keysToLabels(this, peer)
            }, state, options);
        }
    }
};


internals.xor = function (key, value, peers, parent, state, options) {

    const present = [];
    for (let i = 0; i < peers.length; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist !== undefined) {
            present.push(peer);
        }
    }

    if (present.length === 1) {
        return;
    }

    const context = { peers, peersWithLabels: internals.keysToLabels(this, peers) };

    if (present.length === 0) {
        return this.createError('object.missing', context, state, options);
    }

    context.present = present;
    context.presentWithLabels = internals.keysToLabels(this, present);

    return this.createError('object.xor', context, state, options);
};


internals.oxor = function (key, value, peers, parent, state, options) {

    const present = [];
    for (let i = 0; i < peers.length; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist !== undefined) {
            present.push(peer);
        }
    }

    if (!present.length ||
        present.length === 1) {

        return;
    }

    const context = { peers, peersWithLabels: internals.keysToLabels(this, peers) };
    context.present = present;
    context.presentWithLabels = internals.keysToLabels(this, present);

    return this.createError('object.oxor', context, state, options);
};


internals.or = function (key, value, peers, parent, state, options) {

    for (let i = 0; i < peers.length; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist !== undefined) {
            return;
        }
    }

    return this.createError('object.missing', {
        peers,
        peersWithLabels: internals.keysToLabels(this, peers)
    }, state, options);
};


internals.and = function (key, value, peers, parent, state, options) {

    const missing = [];
    const present = [];
    const count = peers.length;
    for (let i = 0; i < count; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist === undefined) {

            missing.push(peer);
        }
        else {
            present.push(peer);
        }
    }

    const aon = (missing.length === count || present.length === count);

    if (!aon) {

        return this.createError('object.and', {
            present,
            presentWithLabels: internals.keysToLabels(this, present),
            missing,
            missingWithLabels: internals.keysToLabels(this, missing)
        }, state, options);
    }
};


internals.nand = function (key, value, peers, parent, state, options) {

    const present = [];
    for (let i = 0; i < peers.length; ++i) {
        const peer = peers[i];
        const keysExist = Hoek.reach(parent, peer, { functions: true });
        if (keysExist !== undefined) {

            present.push(peer);
        }
    }

    const main = peers[0];
    const values = peers.slice(1);
    const allPresent = (present.length === peers.length);
    return allPresent ? this.createError('object.nand', {
        main,
        mainWithLabel: internals.keysToLabels(this, main),
        peers: values,
        peersWithLabels: internals.keysToLabels(this, values)
    }, state, options) : null;
};


module.exports = new internals.Object();