'use strict';

const { assert, merge } = require('@hapi/hoek');

const Any = require('./any');
const Common = require('../common');
const Compile = require('../compile');
const Errors = require('../errors');
const Ref = require('../ref');


const internals = {};


module.exports = Any.extend({

    type: 'alternatives',

    flags: {

        match: { default: 'any' }                 // 'any', 'one', 'all'
    },

    terms: {

        matches: { init: [], register: Ref.toSibling }
    },

    args(schema, ...schemas) {

        if (schemas.length === 1) {
            if (Array.isArray(schemas[0])) {
                return schema.try(...schemas[0]);
            }
        }

        return schema.try(...schemas);
    },

    validate(value, helpers) {

        const { schema, error, state, prefs } = helpers;

        // Match all or one

        if (schema._flags.match) {
            const matched = [];
            const failed = [];

            for (let i = 0; i < schema.$_terms.matches.length; ++i) {
                const item = schema.$_terms.matches[i];
                const localState = state.nest(item.schema, `match.${i}`);
                localState.snapshot();

                const result = item.schema.$_validate(value, localState, prefs);
                if (!result.errors) {
                    matched.push(result.value);
                    localState.commit();
                }
                else {
                    failed.push(result.errors);
                    localState.restore();
                }
            }

            if (matched.length === 0) {
                const context = {
                    details: failed.map((f) => Errors.details(f, { override: false }))
                };

                return { errors: error('alternatives.any', context) };
            }

            // Match one

            if (schema._flags.match === 'one') {
                return matched.length === 1 ? { value: matched[0] } : { errors: error('alternatives.one') };
            }

            // Match all

            if (matched.length !== schema.$_terms.matches.length) {
                const context = {
                    details: failed.map((f) => Errors.details(f, { override: false }))
                };

                return { errors: error('alternatives.all', context) };
            }

            const isAnyObj = (alternative) => {

                return alternative.$_terms.matches.some((v) => {

                    return v.schema.type === 'object' ||
                        (v.schema.type === 'alternatives' && isAnyObj(v.schema));
                });
            };

            return isAnyObj(schema) ? { value: matched.reduce((acc, v) => merge(acc, v, { mergeArrays: false })) } : { value: matched[matched.length - 1] };
        }

        // Match any

        const errors = [];
        for (let i = 0; i < schema.$_terms.matches.length; ++i) {
            const item = schema.$_terms.matches[i];

            // Try

            if (item.schema) {
                const localState = state.nest(item.schema, `match.${i}`);
                localState.snapshot();

                const result = item.schema.$_validate(value, localState, prefs);
                if (!result.errors) {
                    localState.commit();
                    return result;
                }

                localState.restore();
                errors.push({ schema: item.schema, reports: result.errors });
                continue;
            }

            // Conditional

            const input = item.ref ? item.ref.resolve(value, state, prefs) : value;
            const tests = item.is ? [item] : item.switch;

            for (let j = 0; j < tests.length; ++j) {
                const test = tests[j];
                const { is, then, otherwise } = test;

                const id = `match.${i}${item.switch ? '.' + j : ''}`;
                if (!is.$_match(input, state.nest(is, `${id}.is`), prefs)) {
                    if (otherwise) {
                        return otherwise.$_validate(value, state.nest(otherwise, `${id}.otherwise`), prefs);
                    }
                }
                else if (then) {
                    return then.$_validate(value, state.nest(then, `${id}.then`), prefs);
                }
            }
        }

        return internals.errors(errors, helpers);
    },

    rules: {

        conditional: {
            method(condition, options) {

                assert(!this._flags._endedSwitch, 'Unreachable condition');
                assert(!this._flags.match, 'Cannot combine match mode', this._flags.match, 'with conditional rule');
                assert(options.break === undefined, 'Cannot use break option with alternatives conditional');

                const obj = this.clone();

                const match = Compile.when(obj, condition, options);
                const conditions = match.is ? [match] : match.switch;
                for (const item of conditions) {
                    if (item.then &&
                        item.otherwise) {

                        obj.$_setFlag('_endedSwitch', true, { clone: false });
                        break;
                    }
                }

                obj.$_terms.matches.push(match);
                return obj.$_mutateRebuild();
            }
        },

        match: {
            method(mode) {

                assert(['any', 'one', 'all'].includes(mode), 'Invalid alternatives match mode', mode);

                if (mode !== 'any') {
                    for (const match of this.$_terms.matches) {
                        assert(match.schema, 'Cannot combine match mode', mode, 'with conditional rules');
                    }
                }

                return this.$_setFlag('match', mode);
            }
        },

        try: {
            method(...schemas) {

                assert(schemas.length, 'Missing alternative schemas');
                Common.verifyFlat(schemas, 'try');

                assert(!this._flags._endedSwitch, 'Unreachable condition');

                const obj = this.clone();
                for (const schema of schemas) {
                    obj.$_terms.matches.push({ schema: obj.$_compile(schema) });
                }

                return obj.$_mutateRebuild();
            }
        }
    },

    overrides: {

        label(name) {

            const obj = this.$_parent('label', name);
            const each = (item, source) => {

                return source.path[0] !== 'is' && typeof item._flags.label !== 'string' ? item.label(name) : undefined;
            };

            return obj.$_modify({ each, ref: false });
        },

        isAsync() {

            if (this.$_terms.externals?.length) {
                return true;
            }

            for (const match of this.$_terms.matches) {

                if (match.schema?.isAsync()) {
                    return true;
                }

                if (match.then?.isAsync()) {
                    return true;
                }

                if (match.otherwise?.isAsync()) {
                    return true;
                }
            }

            return false;
        }
    },

    rebuild(schema) {

        // Flag when an alternative type is an array

        const each = (item) => {

            if (Common.isSchema(item) &&
                item.type === 'array') {

                schema.$_setFlag('_arrayItems', true, { clone: false });
            }
        };

        schema.$_modify({ each });
    },

    manifest: {

        build(obj, desc) {

            if (desc.matches) {
                for (const match of desc.matches) {
                    const { schema, ref, is, not, then, otherwise } = match;
                    if (schema) {
                        obj = obj.try(schema);
                    }
                    else if (ref) {
                        obj = obj.conditional(ref, { is, then, not, otherwise, switch: match.switch });
                    }
                    else {
                        obj = obj.conditional(is, { then, otherwise });
                    }
                }
            }

            return obj;
        }
    },

    messages: {
        'alternatives.all': '{{#label}} does not match all of the required types',
        'alternatives.any': '{{#label}} does not match any of the allowed types',
        'alternatives.match': '{{#label}} does not match any of the allowed types',
        'alternatives.one': '{{#label}} matches more than one allowed type',
        'alternatives.types': '{{#label}} must be one of {{#types}}'
    }
});


// Helpers

internals.errors = function (failures, { error, state }) {

    // Nothing matched due to type criteria rules

    if (!failures.length) {
        return { errors: error('alternatives.any') };
    }

    // Single error

    if (failures.length === 1) {
        return { errors: failures[0].reports };
    }

    // Analyze reasons

    const valids = new Set();
    const complex = [];

    for (const { reports, schema } of failures) {

        // Multiple errors (!abortEarly)

        if (reports.length > 1) {
            return internals.unmatched(failures, error);
        }

        // Custom error

        const report = reports[0];
        if (report instanceof Errors.Report === false) {
            return internals.unmatched(failures, error);
        }

        // Internal object or array error

        if (report.state.path.length !== state.path.length) {
            complex.push({ type: schema.type, report });
            continue;
        }

        // Valids

        if (report.code === 'any.only') {
            for (const valid of report.local.valids) {
                valids.add(valid);
            }

            continue;
        }

        // Base type

        const [type, code] = report.code.split('.');
        if (code !== 'base') {
            complex.push({ type: schema.type, report });
        }
        else if (report.code === 'object.base') {
            valids.add(report.local.type);
        }
        else {
            valids.add(type);
        }
    }

    // All errors are base types or valids

    if (!complex.length) {
        return { errors: error('alternatives.types', { types: [...valids] }) };
    }

    // Single complex error

    if (complex.length === 1) {
        return { errors: complex[0].report };
    }

    return internals.unmatched(failures, error);
};


internals.unmatched = function (failures, error) {

    const errors = [];
    for (const failure of failures) {
        errors.push(...failure.reports);
    }

    return { errors: error('alternatives.match', Errors.details(errors, { override: false })) };
};
