import {
    Keyword,
    JsonSchemaReducerParams,
    JsonSchemaValidatorParams,
    ValidationPath,
    ValidationReturnType,
    ValidationAnnotation
} from "../Keyword";
import { isSchemaNode, SchemaNode } from "../types";
import settings from "../settings";
import { getValue } from "../utils/getValue";
import sanitizeErrors from "../utils/sanitizeErrors";
import { isObject } from "../utils/isObject";
import { validateNode } from "../validateNode";
import { joinDynamicId } from "../SchemaNode";
import { collectValidationErrors } from "src/utils/collectValidationErrors";

const KEYWORD = "oneOf";
const { DECLARATOR_ONEOF } = settings;

export const oneOfKeyword: Keyword = {
    id: KEYWORD,
    keyword: KEYWORD,
    parse: parseOneOf,
    addReduce: (node) => node[KEYWORD] != null,
    reduce: reduceOneOf,
    addValidate: (node) => node[KEYWORD] != null,
    validate: oneOfValidator
};

export const oneOfFuzzyKeyword: Keyword = {
    id: "oneOf-fuzzy",
    keyword: "oneOf",
    parse: parseOneOf,
    addReduce: (node) => node.oneOf != null,
    reduce: reduceOneOfFuzzy,
    addValidate: (node) => node.oneOf != null,
    validate: oneOfValidator
};

export function parseOneOf(node: SchemaNode) {
    const { schema, evaluationPath, schemaLocation } = node;
    if (schema[KEYWORD] == null) {
        return;
    }
    if (!Array.isArray(schema[KEYWORD])) {
        return node.createError("schema-error", {
            pointer: schemaLocation,
            schema,
            value: schema[KEYWORD],
            message: `Keyword '${KEYWORD}' must be an array - received '${typeof schema[KEYWORD]}'`
        });
    }
    if (schema[KEYWORD].length === 0) {
        return;
    }

    node[KEYWORD] = schema[KEYWORD].map((s, index) =>
        node.compileSchema(s, `${evaluationPath}/${KEYWORD}/${index}`, `${schemaLocation}/${KEYWORD}/${index}`)
    );
    return collectValidationErrors([], ...node[KEYWORD]);
}

function reduceOneOf({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
    if (node.oneOf == null) {
        return;
    }

    // !keyword: oneOfProperty
    // an additional <DECLARATOR_ONEOF> (default `oneOfProperty`) on the schema will exactly determine the
    // oneOf value (if set in data)
    if (data != null && node.schema[DECLARATOR_ONEOF]) {
        return reduceOneOfDeclarator({ node, data, pointer, path });
    }

    const matches: { index: number; node: SchemaNode }[] = [];
    const errors: ValidationReturnType[] = [];
    for (let i = 0; i < node.oneOf.length; i += 1) {
        const validationErrors = validateNode(node.oneOf[i], data, pointer, path);
        if (validationErrors.length === 0) {
            matches.push({ index: i, node: node.oneOf[i] });
        } else {
            errors.push(...validationErrors);
        }
    }

    if (matches.length === 1) {
        const { node, index } = matches[0];
        const { node: reducedNode, error } = node.reduceNode(data, { pointer, path });

        if (reducedNode) {
            const nestedDynamicId = reducedNode.dynamicId?.replace(node.dynamicId, "") ?? "";
            const dynamicId = nestedDynamicId === "" ? `oneOf/${index}` : nestedDynamicId;

            reducedNode.oneOfIndex = index; // @evaluation-info
            reducedNode.dynamicId = joinDynamicId(reducedNode.dynamicId, `+${node.schemaLocation}(${dynamicId})`);
            return reducedNode;
        }
        return error;
    }

    if (matches.length === 0) {
        return node.createError("one-of-error", {
            value: JSON.stringify(data),
            pointer,
            schema: node.schema,
            oneOf: node.schema.oneOf,
            errors
        });
    }

    return node.createError("one-of-error", {
        value: JSON.stringify(data),
        pointer,
        schema: node.schema,
        oneOf: node.schema.oneOf,
        errors
    });
}

/**
 * Returns matching oneOf schema identified by matching schema for oneOfProperty
 */
export function reduceOneOfDeclarator({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
    if (node.oneOf == null) {
        return;
    }

    const oneOfProperty = node.schema[DECLARATOR_ONEOF];
    const oneOfPropertyValue = getValue(data, oneOfProperty);

    // in this case, we also fail when data undefined as this always is valid,
    // but not in context on an expected oneOfProperty
    if (data === undefined || oneOfPropertyValue === undefined) {
        return node.createError("missing-one-of-property-error", {
            oneOfProperty,
            pointer,
            schema: node.schema,
            value: data
        });
    }

    // find oneOf schema that has a matching oneOfProperty to the current input data
    // TODO throw an error if multiple matches were found
    const errors: ValidationReturnType = [];
    for (let i = 0; i < node.oneOf.length; i += 1) {
        const { node: resultNode } = node.oneOf[i].getNodeChild(oneOfProperty, data);
        if (!isSchemaNode(resultNode)) {
            // one of the oneOf schemas has a missing oneOfTypeProperty
            // TODO this still might succeed
            // TODO there is a possibility this throws an invalid error as we use input data
            return node.createError("missing-one-of-declarator-error", {
                declarator: DECLARATOR_ONEOF,
                oneOfProperty,
                schemaLocation: node.oneOf[i].schemaLocation,
                pointer: `${pointer}/oneOf/${i}`,
                schema: node.schema,
                value: data
            });
        }

        // collect errors in case we fail finding a matching schema
        const result = sanitizeErrors(
            validateNode(resultNode, oneOfPropertyValue, `${pointer}/${oneOfProperty}`, path)
        );

        if (result.length > 0) {
            errors.push(...result);
        } else {
            // return at once when we found a schema
            // TODO should check all oneOf-schema
            const { node: reducedNode } = node.oneOf[i].reduceNode(data, { pointer, path });
            if (reducedNode) {
                reducedNode.oneOfIndex = i; // @evaluation-info
                return reducedNode;
            }
        }
    }

    return node.createError("one-of-property-error", {
        property: oneOfProperty,
        value: data,
        pointer,
        schema: node.schema,
        errors
    });
}

/**
 * Returns a ranking for the data and given schema
 *
 * @param draft
 * @param - json schema type: object
 * @param data
 * @param [pointer]
 * @return ranking value (higher is better)
 */
function fuzzyObjectValue(node: SchemaNode, data: Record<string, unknown>, pointer: string, path: ValidationPath) {
    if (data == null || node.properties == null) {
        return -1;
    }
    let value = 0;
    const keys = Object.keys(node.properties ?? {});
    for (const key of keys) {
        if (data[key]) {
            if (validateNode(node.properties[key], data[key], pointer, path).length === 0) {
                value += 1;
            }
        }
    }
    return value;
}

/**
 * Selects and returns a oneOf schema for the given data
 *
 * @param draft
 * @param data
 * @param [schema] - current json schema containing property oneOf
 * @param [pointer] - json pointer to data
 * @return oneOf schema or an error
 */
export function reduceOneOfFuzzy({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
    // @todo: usingMergeNode may add reducers that are no longer available
    if (node.oneOf == null) {
        return node;
    }

    const oneOfResult = reduceOneOf({ node, data, pointer, path });
    if (isSchemaNode(oneOfResult)) {
        return oneOfResult;
    }

    // fuzzy match oneOf
    if (isObject(data)) {
        let nodeOfItem: SchemaNode | undefined;
        let schemaOfIndex = -1;
        let fuzzyGreatest = 0;

        for (let i = 0; i < node.oneOf.length; i += 1) {
            const oneNode = node.oneOf[i];
            const fuzzyValue = fuzzyObjectValue(oneNode, data, pointer, path);

            if (fuzzyGreatest < fuzzyValue) {
                fuzzyGreatest = fuzzyValue;
                nodeOfItem = oneNode;
                schemaOfIndex = i;
            }
        }

        if (nodeOfItem === undefined) {
            return node.createError("one-of-error", {
                value: JSON.stringify(data),
                pointer,
                schema: node.schema,
                oneOf: node.schema.oneOf
            });
        }

        const { node: reducedNode, error } = nodeOfItem.reduceNode(data, { pointer, path });
        if (reducedNode) {
            reducedNode.oneOfIndex = schemaOfIndex; // @evaluation-info
            return reducedNode;
        }
        return error;
    }

    return oneOfResult;
}

function validateFromDeclarator({ node, data, pointer = "#", path }: JsonSchemaValidatorParams) {
    const { oneOf, schema } = node;
    if (!oneOf) {
        return;
    }

    // with a declarator we only validate by a declarator to retrieve matches.
    // - if a single match was found, we return validation errors if any
    // - if no match was found we return a one-of-error
    // - if multiples matches were found we return a multiple-one-of-error
    const oneOfProperty = schema[DECLARATOR_ONEOF];
    const oneOfValue = getValue(data, oneOfProperty);
    const matches: { index: number; node: SchemaNode }[] = [];
    const errors: ValidationReturnType = [];
    for (const oneOfNode of oneOf) {
        const { node: oneOfPropertyNode, error } = oneOfNode.getNodeChild(oneOfProperty, oneOfValue);
        if (oneOfPropertyNode) {
            const validationResult = validateNode(oneOfPropertyNode, oneOfValue, `${pointer}/${oneOfProperty}`, path);
            if (validationResult.length > 0) {
                errors.push(...validationResult);
            } else {
                matches.push({ index: oneOf.indexOf(oneOfNode), node: oneOfNode });
            }
        } else {
            console.log(
                `jlib oneOf error: failed getting schema for '${oneOfProperty}' to resolve ${DECLARATOR_ONEOF} in ${pointer}/oneOf/${oneOf.indexOf(oneOfNode)}`,
                error
            );
        }
    }

    if (matches.length === 1) {
        const match = matches[0];
        match.node.oneOfIndex = match.index; // @evaluation-info
        return validateNode(match.node, data, pointer, path);
    }

    if (matches.length > 1) {
        return node.createError("multiple-one-of-error", {
            value: data,
            pointer,
            schema,
            matches
        });
    }

    return node.createError("one-of-error", {
        value: JSON.stringify(data),
        pointer,
        schema,
        oneOf: schema.oneOf,
        errors
    });
}

function oneOfValidator({ node, data, pointer = "#", path }: JsonSchemaValidatorParams) {
    const { oneOf, schema } = node;
    if (!oneOf) {
        return;
    }

    if (schema[DECLARATOR_ONEOF]) {
        return validateFromDeclarator({ node, data, pointer, path });
    }

    const matches: { index: number; node: SchemaNode }[] = [];
    const errors: ValidationReturnType = [];
    for (let i = 0; i < oneOf.length; i += 1) {
        const validationResult = validateNode(oneOf[i], data, pointer, path);
        if (validationResult.length > 0) {
            errors.push(...validationResult);
        } else {
            matches.push({ index: i, node: oneOf[i] });
        }
    }

    if (matches.length === 1) {
        const { node, index } = matches[0];
        node.oneOfIndex = index; // @evaluation-info
        return undefined;
    }

    if (matches.length > 1) {
        return node.createError("multiple-one-of-error", {
            value: data,
            pointer,
            schema,
            matches
        });
    }

    return node.createError("one-of-error", {
        value: JSON.stringify(data),
        pointer,
        schema,
        oneOf: schema.oneOf,
        errors
    });
}
