/*
 * Copyright (C) 2019-2020  Alex Yatskov <alex@foosoft.net>
 * Author: Alex Yatskov <alex@foosoft.net>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */


class JsonSchemaProxyHandler {
    constructor(schema) {
        this._schema = schema;
    }

    getPrototypeOf(target) {
        return Object.getPrototypeOf(target);
    }

    setPrototypeOf() {
        throw new Error('setPrototypeOf not supported');
    }

    isExtensible(target) {
        return Object.isExtensible(target);
    }

    preventExtensions(target) {
        Object.preventExtensions(target);
        return true;
    }

    getOwnPropertyDescriptor(target, property) {
        return Object.getOwnPropertyDescriptor(target, property);
    }

    defineProperty() {
        throw new Error('defineProperty not supported');
    }

    has(target, property) {
        return property in target;
    }

    get(target, property) {
        if (typeof property === 'symbol') {
            return target[property];
        }

        if (Array.isArray(target)) {
            if (typeof property === 'string' && /^\d+$/.test(property)) {
                property = parseInt(property, 10);
            } else if (typeof property === 'string') {
                return target[property];
            }
        }

        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
        if (propertySchema === null) {
            return;
        }

        const value = target[property];
        return value !== null && typeof value === 'object' ? JsonSchema.createProxy(value, propertySchema) : value;
    }

    set(target, property, value) {
        if (Array.isArray(target)) {
            if (typeof property === 'string' && /^\d+$/.test(property)) {
                property = parseInt(property, 10);
                if (property > target.length) {
                    throw new Error('Array index out of range');
                }
            } else if (typeof property === 'string') {
                target[property] = value;
                return true;
            }
        }

        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
        if (propertySchema === null) {
            throw new Error(`Property ${property} not supported`);
        }

        value = JsonSchema.isolate(value);

        const error = JsonSchemaProxyHandler.validate(value, propertySchema);
        if (error !== null) {
            throw new Error(`Invalid value: ${error}`);
        }

        target[property] = value;
        return true;
    }

    deleteProperty(target, property) {
        const required = this._schema.required;
        if (Array.isArray(required) && required.includes(property)) {
            throw new Error(`${property} cannot be deleted`);
        }
        return Reflect.deleteProperty(target, property);
    }

    ownKeys(target) {
        return Reflect.ownKeys(target);
    }

    apply() {
        throw new Error('apply not supported');
    }

    construct() {
        throw new Error('construct not supported');
    }

    static getPropertySchema(schema, property) {
        const type = schema.type;
        if (Array.isArray(type)) {
            throw new Error(`Ambiguous property type for ${property}`);
        }
        switch (type) {
            case 'object':
            {
                const properties = schema.properties;
                if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
                    if (Object.prototype.hasOwnProperty.call(properties, property)) {
                        return properties[property];
                    }
                }

                const additionalProperties = schema.additionalProperties;
                return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null;
            }
            case 'array':
            {
                const items = schema.items;
                return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null;
            }
            default:
                return null;
        }
    }

    static validate(value, schema) {
        const type = JsonSchemaProxyHandler.getValueType(value);
        const schemaType = schema.type;
        if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) {
            return `Value type ${type} does not match schema type ${schemaType}`;
        }

        const schemaEnum = schema.enum;
        if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) {
            return 'Invalid enum value';
        }

        switch (type) {
            case 'number':
                return JsonSchemaProxyHandler.validateNumber(value, schema);
            case 'string':
                return JsonSchemaProxyHandler.validateString(value, schema);
            case 'array':
                return JsonSchemaProxyHandler.validateArray(value, schema);
            case 'object':
                return JsonSchemaProxyHandler.validateObject(value, schema);
            default:
                return null;
        }
    }

    static validateNumber(value, schema) {
        const multipleOf = schema.multipleOf;
        if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) {
            return `Number is not a multiple of ${multipleOf}`;
        }

        const minimum = schema.minimum;
        if (typeof minimum === 'number' && value < minimum) {
            return `Number is less than ${minimum}`;
        }

        const exclusiveMinimum = schema.exclusiveMinimum;
        if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) {
            return `Number is less than or equal to ${exclusiveMinimum}`;
        }

        const maximum = schema.maximum;
        if (typeof maximum === 'number' && value > maximum) {
            return `Number is greater than ${maximum}`;
        }

        const exclusiveMaximum = schema.exclusiveMaximum;
        if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) {
            return `Number is greater than or equal to ${exclusiveMaximum}`;
        }

        return null;
    }

    static validateString(value, schema) {
        const minLength = schema.minLength;
        if (typeof minLength === 'number' && value.length < minLength) {
            return 'String length too short';
        }

        const maxLength = schema.minLength;
        if (typeof maxLength === 'number' && value.length > maxLength) {
            return 'String length too long';
        }

        return null;
    }

    static validateArray(value, schema) {
        const minItems = schema.minItems;
        if (typeof minItems === 'number' && value.length < minItems) {
            return 'Array length too short';
        }

        const maxItems = schema.maxItems;
        if (typeof maxItems === 'number' && value.length > maxItems) {
            return 'Array length too long';
        }

        return null;
    }

    static validateObject(value, schema) {
        const properties = new Set(Object.getOwnPropertyNames(value));

        const required = schema.required;
        if (Array.isArray(required)) {
            for (const property of required) {
                if (!properties.has(property)) {
                    return `Missing property ${property}`;
                }
            }
        }

        const minProperties = schema.minProperties;
        if (typeof minProperties === 'number' && properties.length < minProperties) {
            return 'Not enough object properties';
        }

        const maxProperties = schema.maxProperties;
        if (typeof maxProperties === 'number' && properties.length > maxProperties) {
            return 'Too many object properties';
        }

        for (const property of properties) {
            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
            if (propertySchema === null) {
                return `No schema found for ${property}`;
            }
            const error = JsonSchemaProxyHandler.validate(value[property], propertySchema);
            if (error !== null) {
                return error;
            }
        }

        return null;
    }

    static isValueTypeAny(value, type, schemaTypes) {
        if (typeof schemaTypes === 'string') {
            return JsonSchemaProxyHandler.isValueType(value, type, schemaTypes);
        } else if (Array.isArray(schemaTypes)) {
            for (const schemaType of schemaTypes) {
                if (JsonSchemaProxyHandler.isValueType(value, type, schemaType)) {
                    return true;
                }
            }
            return false;
        }
        return true;
    }

    static isValueType(value, type, schemaType) {
        return (
            type === schemaType ||
            (schemaType === 'integer' && Math.floor(value) === value)
        );
    }

    static getValueType(value) {
        const type = typeof value;
        if (type === 'object') {
            if (value === null) { return 'null'; }
            if (Array.isArray(value)) { return 'array'; }
        }
        return type;
    }

    static valuesAreEqualAny(value1, valueList) {
        for (const value2 of valueList) {
            if (JsonSchemaProxyHandler.valuesAreEqual(value1, value2)) {
                return true;
            }
        }
        return false;
    }

    static valuesAreEqual(value1, value2) {
        return value1 === value2;
    }

    static getDefaultTypeValue(type) {
        if (typeof type === 'string') {
            switch (type) {
                case 'null':
                    return null;
                case 'boolean':
                    return false;
                case 'number':
                case 'integer':
                    return 0;
                case 'string':
                    return '';
                case 'array':
                    return [];
                case 'object':
                    return {};
            }
        }
        return null;
    }

    static getValidValueOrDefault(schema, value) {
        let type = JsonSchemaProxyHandler.getValueType(value);
        const schemaType = schema.type;
        if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) {
            let assignDefault = true;

            const schemaDefault = schema.default;
            if (typeof schemaDefault !== 'undefined') {
                value = JsonSchema.isolate(schemaDefault);
                type = JsonSchemaProxyHandler.getValueType(value);
                assignDefault = !JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType);
            }

            if (assignDefault) {
                value = JsonSchemaProxyHandler.getDefaultTypeValue(schemaType);
                type = JsonSchemaProxyHandler.getValueType(value);
            }
        }

        switch (type) {
            case 'object':
                value = JsonSchemaProxyHandler.populateObjectDefaults(value, schema);
                break;
            case 'array':
                value = JsonSchemaProxyHandler.populateArrayDefaults(value, schema);
                break;
        }

        return value;
    }

    static populateObjectDefaults(value, schema) {
        const properties = new Set(Object.getOwnPropertyNames(value));

        const required = schema.required;
        if (Array.isArray(required)) {
            for (const property of required) {
                properties.delete(property);

                const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
                if (propertySchema === null) { continue; }
                value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);
            }
        }

        for (const property of properties) {
            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
            if (propertySchema === null) {
                Reflect.deleteProperty(value, property);
            } else {
                value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);
            }
        }

        return value;
    }

    static populateArrayDefaults(value, schema) {
        for (let i = 0, ii = value.length; i < ii; ++i) {
            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i);
            if (propertySchema === null) { continue; }
            value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]);
        }

        return value;
    }
}

class JsonSchema {
    static createProxy(target, schema) {
        return new Proxy(target, new JsonSchemaProxyHandler(schema));
    }

    static getValidValueOrDefault(schema, value) {
        return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value);
    }

    static isolate(value) {
        if (value === null) { return null; }

        switch (typeof value) {
            case 'boolean':
            case 'number':
            case 'string':
            case 'bigint':
            case 'symbol':
                return value;
        }

        const stringValue = JSON.stringify(value);
        return typeof stringValue === 'string' ? JSON.parse(stringValue) : null;
    }
}