'use strict';

const Assert = require('@hapi/hoek/lib/assert');
const DeepEqual = require('@hapi/hoek/lib/deepEqual');
const Reach = require('@hapi/hoek/lib/reach');

const Any = require('./any');
const Common = require('../common');
const Compile = require('../compile');


const internals = {};


module.exports = Any.extend({

    type: 'array',

    flags: {

        single: { default: false },
        sparse: { default: false }
    },

    terms: {

        items: { init: [], manifest: 'schema' },
        ordered: { init: [], manifest: 'schema' },

        _exclusions: { init: [] },
        _inclusions: { init: [] },
        _requireds: { init: [] }
    },

    coerce: {
        from: 'object',
        method(value, { schema, state, prefs }) {

            if (!Array.isArray(value)) {
                return;
            }

            const sort = schema.$_getRule('sort');
            if (!sort) {
                return;
            }

            return internals.sort(schema, value, sort.args.options, state, prefs);
        }
    },

    validate(value, { schema, error }) {

        if (!Array.isArray(value)) {
            if (schema._flags.single) {
                const single = [value];
                single[Common.symbols.arraySingle] = true;
                return { value: single };
            }

            return { errors: error('array.base') };
        }

        if (!schema.$_getRule('items') &&
            !schema.$_terms.externals) {

            return;
        }

        return { value: value.slice() };        // Clone the array so that we don't modify the original
    },

    rules: {

        has: {
            method(schema) {

                schema = this.$_compile(schema, { appendPath: true });
                const obj = this.$_addRule({ name: 'has', args: { schema } });
                obj.$_mutateRegister(schema);
                return obj;
            },
            validate(value, { state, prefs, error }, { schema: has }) {

                const ancestors = [value, ...state.ancestors];
                for (let i = 0; i < value.length; ++i) {
                    const localState = state.localize([...state.path, i], ancestors, has);
                    if (has.$_match(value[i], localState, prefs)) {
                        return value;
                    }
                }

                const patternLabel = has._flags.label;
                if (patternLabel) {
                    return error('array.hasKnown', { patternLabel });
                }

                return error('array.hasUnknown', null);
            },
            multi: true
        },

        items: {
            method(...schemas) {

                Common.verifyFlat(schemas, 'items');

                const obj = this.$_addRule('items');

                for (let i = 0; i < schemas.length; ++i) {
                    const type = Common.tryWithPath(() => this.$_compile(schemas[i]), i, { append: true });
                    obj.$_terms.items.push(type);
                }

                return obj.$_mutateRebuild();
            },
            validate(value, { schema, error, state, prefs, errorsArray }) {

                const requireds = schema.$_terms._requireds.slice();
                const ordereds = schema.$_terms.ordered.slice();
                const inclusions = [...schema.$_terms._inclusions, ...requireds];

                const wasArray = !value[Common.symbols.arraySingle];
                delete value[Common.symbols.arraySingle];

                const errors = errorsArray();

                let il = value.length;
                for (let i = 0; i < il; ++i) {
                    const item = value[i];

                    let errored = false;
                    let isValid = false;

                    const key = wasArray ? i : new Number(i);       // eslint-disable-line no-new-wrappers
                    const path = [...state.path, key];

                    // Sparse

                    if (!schema._flags.sparse &&
                        item === undefined) {

                        errors.push(error('array.sparse', { key, path, pos: i, value: undefined }, state.localize(path)));
                        if (prefs.abortEarly) {
                            return errors;
                        }

                        ordereds.shift();
                        continue;
                    }

                    // Exclusions

                    const ancestors = [value, ...state.ancestors];

                    for (const exclusion of schema.$_terms._exclusions) {
                        if (!exclusion.$_match(item, state.localize(path, ancestors, exclusion), prefs, { presence: 'ignore' })) {
                            continue;
                        }

                        errors.push(error('array.excludes', { pos: i, value: item }, state.localize(path)));
                        if (prefs.abortEarly) {
                            return errors;
                        }

                        errored = true;
                        ordereds.shift();
                        break;
                    }

                    if (errored) {
                        continue;
                    }

                    // Ordered

                    if (schema.$_terms.ordered.length) {
                        if (ordereds.length) {
                            const ordered = ordereds.shift();
                            const res = ordered.$_validate(item, state.localize(path, ancestors, ordered), prefs);
                            if (!res.errors) {
                                if (ordered._flags.result === 'strip') {
                                    internals.fastSplice(value, i);
                                    --i;
                                    --il;
                                }
                                else if (!schema._flags.sparse && res.value === undefined) {
                                    errors.push(error('array.sparse', { key, path, pos: i, value: undefined }, state.localize(path)));
                                    if (prefs.abortEarly) {
                                        return errors;
                                    }

                                    continue;
                                }
                                else {
                                    value[i] = res.value;
                                }
                            }
                            else {
                                errors.push(...res.errors);
                                if (prefs.abortEarly) {
                                    return errors;
                                }
                            }

                            continue;
                        }
                        else if (!schema.$_terms.items.length) {
                            errors.push(error('array.orderedLength', { pos: i, limit: schema.$_terms.ordered.length }));
                            if (prefs.abortEarly) {
                                return errors;
                            }

                            break;      // No reason to continue since there are no other rules to validate other than array.orderedLength
                        }
                    }

                    // Requireds

                    const requiredChecks = [];
                    let jl = requireds.length;
                    for (let j = 0; j < jl; ++j) {
                        const localState = state.localize(path, ancestors, requireds[j]);
                        localState.snapshot();

                        const res = requireds[j].$_validate(item, localState, prefs);
                        requiredChecks[j] = res;

                        if (!res.errors) {
                            localState.commit();
                            value[i] = res.value;
                            isValid = true;
                            internals.fastSplice(requireds, j);
                            --j;
                            --jl;

                            if (!schema._flags.sparse &&
                                res.value === undefined) {

                                errors.push(error('array.sparse', { key, path, pos: i, value: undefined }, state.localize(path)));
                                if (prefs.abortEarly) {
                                    return errors;
                                }
                            }

                            break;
                        }

                        localState.restore();
                    }

                    if (isValid) {
                        continue;
                    }

                    // Inclusions

                    const stripUnknown = prefs.stripUnknown && !!prefs.stripUnknown.arrays || false;

                    jl = inclusions.length;
                    for (const inclusion of inclusions) {

                        // Avoid re-running requireds that already didn't match in the previous loop

                        let res;
                        const previousCheck = requireds.indexOf(inclusion);
                        if (previousCheck !== -1) {
                            res = requiredChecks[previousCheck];
                        }
                        else {
                            const localState = state.localize(path, ancestors, inclusion);
                            localState.snapshot();

                            res = inclusion.$_validate(item, localState, prefs);
                            if (!res.errors) {
                                localState.commit();
                                if (inclusion._flags.result === 'strip') {
                                    internals.fastSplice(value, i);
                                    --i;
                                    --il;
                                }
                                else if (!schema._flags.sparse &&
                                    res.value === undefined) {

                                    errors.push(error('array.sparse', { key, path, pos: i, value: undefined }, state.localize(path)));
                                    errored = true;
                                }
                                else {
                                    value[i] = res.value;
                                }

                                isValid = true;
                                break;
                            }

                            localState.restore();
                        }

                        // Return the actual error if only one inclusion defined

                        if (jl === 1) {
                            if (stripUnknown) {
                                internals.fastSplice(value, i);
                                --i;
                                --il;
                                isValid = true;
                                break;
                            }

                            errors.push(...res.errors);
                            if (prefs.abortEarly) {
                                return errors;
                            }

                            errored = true;
                            break;
                        }
                    }

                    if (errored) {
                        continue;
                    }

                    if ((schema.$_terms._inclusions.length || schema.$_terms._requireds.length) &&
                        !isValid) {

                        if (stripUnknown) {
                            internals.fastSplice(value, i);
                            --i;
                            --il;
                            continue;
                        }

                        errors.push(error('array.includes', { pos: i, value: item }, state.localize(path)));
                        if (prefs.abortEarly) {
                            return errors;
                        }
                    }
                }

                if (requireds.length) {
                    internals.fillMissedErrors(schema, errors, requireds, value, state, prefs);
                }

                if (ordereds.length) {
                    internals.fillOrderedErrors(schema, errors, ordereds, value, state, prefs);

                    if (!errors.length) {
                        internals.fillDefault(ordereds, value, state, prefs);
                    }
                }

                return errors.length ? errors : value;
            },

            priority: true,
            manifest: false
        },

        length: {
            method(limit) {

                return this.$_addRule({ name: 'length', args: { limit }, operator: '=' });
            },
            validate(value, helpers, { limit }, { name, operator, args }) {

                if (Common.compare(value.length, limit, operator)) {
                    return value;
                }

                return helpers.error('array.' + name, { limit: args.limit, value });
            },
            args: [
                {
                    name: 'limit',
                    ref: true,
                    assert: Common.limit,
                    message: 'must be a positive integer'
                }
            ]
        },

        max: {
            method(limit) {

                return this.$_addRule({ name: 'max', method: 'length', args: { limit }, operator: '<=' });
            }
        },

        min: {
            method(limit) {

                return this.$_addRule({ name: 'min', method: 'length', args: { limit }, operator: '>=' });
            }
        },

        ordered: {
            method(...schemas) {

                Common.verifyFlat(schemas, 'ordered');

                const obj = this.$_addRule('items');

                for (let i = 0; i < schemas.length; ++i) {
                    const type = Common.tryWithPath(() => this.$_compile(schemas[i]), i, { append: true });
                    internals.validateSingle(type, obj);

                    obj.$_mutateRegister(type);
                    obj.$_terms.ordered.push(type);
                }

                return obj.$_mutateRebuild();
            }
        },

        single: {
            method(enabled) {

                const value = enabled === undefined ? true : !!enabled;
                Assert(!value || !this._flags._arrayItems, 'Cannot specify single rule when array has array items');

                return this.$_setFlag('single', value);
            }
        },

        sort: {
            method(options = {}) {

                Common.assertOptions(options, ['by', 'order']);

                const settings = {
                    order: options.order || 'ascending'
                };

                if (options.by) {
                    settings.by = Compile.ref(options.by, { ancestor: 0 });
                    Assert(!settings.by.ancestor, 'Cannot sort by ancestor');
                }

                return this.$_addRule({ name: 'sort', args: { options: settings } });
            },
            validate(value, { error, state, prefs, schema }, { options }) {

                const { value: sorted, errors } = internals.sort(schema, value, options, state, prefs);
                if (errors) {
                    return errors;
                }

                for (let i = 0; i < value.length; ++i) {
                    if (value[i] !== sorted[i]) {
                        return error('array.sort', { order: options.order, by: options.by ? options.by.key : 'value' });
                    }
                }

                return value;
            },
            convert: true
        },

        sparse: {
            method(enabled) {

                const value = enabled === undefined ? true : !!enabled;

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

                const obj = value ? this.clone() : this.$_addRule('items');
                return obj.$_setFlag('sparse', value, { clone: false });
            }
        },

        unique: {
            method(comparator, options = {}) {

                Assert(!comparator || typeof comparator === 'function' || typeof comparator === 'string', 'comparator must be a function or a string');
                Common.assertOptions(options, ['ignoreUndefined', 'separator']);

                const rule = { name: 'unique', args: { options, comparator } };

                if (comparator) {
                    if (typeof comparator === 'string') {
                        const separator = Common.default(options.separator, '.');
                        rule.path = separator ? comparator.split(separator) : [comparator];
                    }
                    else {
                        rule.comparator = comparator;
                    }
                }

                return this.$_addRule(rule);
            },
            validate(value, { state, error, schema }, { comparator: raw, options }, { comparator, path }) {

                const found = {
                    string: Object.create(null),
                    number: Object.create(null),
                    undefined: Object.create(null),
                    boolean: Object.create(null),
                    bigint: Object.create(null),
                    object: new Map(),
                    function: new Map(),
                    custom: new Map()
                };

                const compare = comparator || DeepEqual;
                const ignoreUndefined = options.ignoreUndefined;

                for (let i = 0; i < value.length; ++i) {
                    const item = path ? Reach(value[i], path) : value[i];
                    const records = comparator ? found.custom : found[typeof item];
                    Assert(records, 'Failed to find unique map container for type', typeof item);

                    if (records instanceof Map) {
                        const entries = records.entries();
                        let current;
                        while (!(current = entries.next()).done) {
                            if (compare(current.value[0], item)) {
                                const localState = state.localize([...state.path, i], [value, ...state.ancestors]);
                                const context = {
                                    pos: i,
                                    value: value[i],
                                    dupePos: current.value[1],
                                    dupeValue: value[current.value[1]]
                                };

                                if (path) {
                                    context.path = raw;
                                }

                                return error('array.unique', context, localState);
                            }
                        }

                        records.set(item, i);
                    }
                    else {
                        if ((!ignoreUndefined || item !== undefined) &&
                            records[item] !== undefined) {

                            const context = {
                                pos: i,
                                value: value[i],
                                dupePos: records[item],
                                dupeValue: value[records[item]]
                            };

                            if (path) {
                                context.path = raw;
                            }

                            const localState = state.localize([...state.path, i], [value, ...state.ancestors]);
                            return error('array.unique', context, localState);
                        }

                        records[item] = i;
                    }
                }

                return value;
            },
            args: ['comparator', 'options'],
            multi: true
        }
    },

    cast: {
        set: {
            from: Array.isArray,
            to(value, helpers) {

                return new Set(value);
            }
        }
    },

    rebuild(schema) {

        schema.$_terms._inclusions = [];
        schema.$_terms._exclusions = [];
        schema.$_terms._requireds = [];

        for (const type of schema.$_terms.items) {
            internals.validateSingle(type, schema);

            if (type._flags.presence === 'required') {
                schema.$_terms._requireds.push(type);
            }
            else if (type._flags.presence === 'forbidden') {
                schema.$_terms._exclusions.push(type);
            }
            else {
                schema.$_terms._inclusions.push(type);
            }
        }

        for (const type of schema.$_terms.ordered) {
            internals.validateSingle(type, schema);
        }
    },

    manifest: {

        build(obj, desc) {

            if (desc.items) {
                obj = obj.items(...desc.items);
            }

            if (desc.ordered) {
                obj = obj.ordered(...desc.ordered);
            }

            return obj;
        }
    },

    messages: {
        'array.base': '{{#label}} must be an array',
        'array.excludes': '{{#label}} contains an excluded value',
        'array.hasKnown': '{{#label}} does not contain at least one required match for type {:#patternLabel}',
        'array.hasUnknown': '{{#label}} does not contain at least one required match',
        'array.includes': '{{#label}} does not match any of the allowed types',
        'array.includesRequiredBoth': '{{#label}} does not contain {{#knownMisses}} and {{#unknownMisses}} other required value(s)',
        'array.includesRequiredKnowns': '{{#label}} does not contain {{#knownMisses}}',
        'array.includesRequiredUnknowns': '{{#label}} does not contain {{#unknownMisses}} required value(s)',
        'array.length': '{{#label}} must contain {{#limit}} items',
        'array.max': '{{#label}} must contain less than or equal to {{#limit}} items',
        'array.min': '{{#label}} must contain at least {{#limit}} items',
        'array.orderedLength': '{{#label}} must contain at most {{#limit}} items',
        'array.sort': '{{#label}} must be sorted in {#order} order by {{#by}}',
        'array.sort.mismatching': '{{#label}} cannot be sorted due to mismatching types',
        'array.sort.unsupported': '{{#label}} cannot be sorted due to unsupported type {#type}',
        'array.sparse': '{{#label}} must not be a sparse array item',
        'array.unique': '{{#label}} contains a duplicate value'
    }
});


// Helpers

internals.fillMissedErrors = function (schema, errors, requireds, value, state, prefs) {

    const knownMisses = [];
    let unknownMisses = 0;
    for (const required of requireds) {
        const label = required._flags.label;
        if (label) {
            knownMisses.push(label);
        }
        else {
            ++unknownMisses;
        }
    }

    if (knownMisses.length) {
        if (unknownMisses) {
            errors.push(schema.$_createError('array.includesRequiredBoth', value, { knownMisses, unknownMisses }, state, prefs));
        }
        else {
            errors.push(schema.$_createError('array.includesRequiredKnowns', value, { knownMisses }, state, prefs));
        }
    }
    else {
        errors.push(schema.$_createError('array.includesRequiredUnknowns', value, { unknownMisses }, state, prefs));
    }
};


internals.fillOrderedErrors = function (schema, errors, ordereds, value, state, prefs) {

    const requiredOrdereds = [];

    for (const ordered of ordereds) {
        if (ordered._flags.presence === 'required') {
            requiredOrdereds.push(ordered);
        }
    }

    if (requiredOrdereds.length) {
        internals.fillMissedErrors(schema, errors, requiredOrdereds, value, state, prefs);
    }
};


internals.fillDefault = function (ordereds, value, state, prefs) {

    const overrides = [];
    let trailingUndefined = true;

    for (let i = ordereds.length - 1; i >= 0; --i) {
        const ordered = ordereds[i];
        const ancestors = [value, ...state.ancestors];
        const override = ordered.$_validate(undefined, state.localize(state.path, ancestors, ordered), prefs).value;

        if (trailingUndefined) {
            if (override === undefined) {
                continue;
            }

            trailingUndefined = false;
        }

        overrides.unshift(override);
    }

    if (overrides.length) {
        value.push(...overrides);
    }
};


internals.fastSplice = function (arr, i) {

    let pos = i;
    while (pos < arr.length) {
        arr[pos++] = arr[pos];
    }

    --arr.length;
};


internals.validateSingle = function (type, obj) {

    if (type.type === 'array' ||
        type._flags._arrayItems) {

        Assert(!obj._flags.single, 'Cannot specify array item with single rule enabled');
        obj.$_setFlag('_arrayItems', true, { clone: false });
    }
};


internals.sort = function (schema, value, settings, state, prefs) {

    const order = settings.order === 'ascending' ? 1 : -1;
    const aFirst = -1 * order;
    const bFirst = order;

    const sort = (a, b) => {

        let compare = internals.compare(a, b, aFirst, bFirst);
        if (compare !== null) {
            return compare;
        }

        if (settings.by) {
            a = settings.by.resolve(a, state, prefs);
            b = settings.by.resolve(b, state, prefs);
        }

        compare = internals.compare(a, b, aFirst, bFirst);
        if (compare !== null) {
            return compare;
        }

        const type = typeof a;
        if (type !== typeof b) {
            throw schema.$_createError('array.sort.mismatching', value, null, state, prefs);
        }

        if (type !== 'number' &&
            type !== 'string') {

            throw schema.$_createError('array.sort.unsupported', value, { type }, state, prefs);
        }

        if (type === 'number') {
            return (a - b) * order;
        }

        return a < b ? aFirst : bFirst;
    };

    try {
        return { value: value.slice().sort(sort) };
    }
    catch (err) {
        return { errors: err };
    }
};


internals.compare = function (a, b, aFirst, bFirst) {

    if (a === b) {
        return 0;
    }

    if (a === undefined) {
        return 1;           // Always last regardless of sort order
    }

    if (b === undefined) {
        return -1;           // Always last regardless of sort order
    }

    if (a === null) {
        return bFirst;
    }

    if (b === null) {
        return aFirst;
    }

    return null;
};
