/* eslint quote-props: 0, max-statements-per-line: ["error", { "max": 2 }] */
import { resolveOneOfFuzzy } from "./features/oneOf";
import getTypeOf from "./getTypeOf";
import merge from "./utils/merge";
import copy from "./utils/copy";
import settings from "./config/settings";
import { JsonSchema, JsonPointer, isJsonError } from "./types";
import { Draft } from "./draft";
import { isEmpty } from "./utils/isEmpty";
import { resolveIfSchema } from "./features/if";
import { mergeAllOfSchema, resolveSchema } from "./features/allOf";
import { resolveDependencies } from "./features/dependencies";
import { mergeSchema } from "./mergeSchema";

export type TemplateOptions = {
    /** Add all properties (required and optional) to the generated data */
    addOptionalProps?: boolean;
    /** Remove data that does not match input schema. Defaults to false */
    removeInvalidData?: boolean;
    /** Set to false to take default values as they are and not extend them.
     *  Defaults to true.
     *  This allows to control template data e.g. enforcing arrays to be empty,
     *  regardless of minItems settings.
     */
    extendDefaults?: boolean;
};

const defaultOptions: TemplateOptions = settings.templateDefaultOptions;

let cache: Record<string, JsonSchema>;
function shouldResolveRef(schema: JsonSchema, pointer: JsonPointer) {
    const { $ref } = schema;
    if ($ref == null) {
        return true;
    }
    const value = cache[pointer] == null || cache[pointer][$ref] == null ? 0 : cache[pointer][$ref];
    return value < settings.GET_TEMPLATE_RECURSION_LIMIT;
}

function resolveRef(draft: Draft, schema: JsonSchema, pointer: JsonPointer) {
    const { $ref } = schema;
    if ($ref == null) {
        return schema;
    }
    // @todo pointer + ref is redundant?
    cache[pointer] = cache[pointer] || {};
    cache[pointer][$ref] = cache[pointer][$ref] || 0;
    cache[pointer][$ref] += 1;
    return draft.resolveRef(schema);
}

function convertValue(type: string, value: any) {
    if (type === "string") {
        return JSON.stringify(value);
    } else if (typeof value !== "string") {
        return null;
    }
    try {
        value = JSON.parse(value);
        if (typeof value === type) {
            return value;
        }
    } catch (e) {} // eslint-disable-line no-empty
    return null;
}

/**
 * Resolves $ref, allOf and anyOf schema-options, returning a combined json-schema.
 * Also returns a pointer-property on schema, that must be used as current pointer.
 *
 * @param draft
 * @param schema
 * @param data
 * @param pointer
 * @return resolved json-schema or input-schema
 */
function createTemplateSchema(
    draft: Draft,
    schema: JsonSchema,
    data: unknown,
    pointer: JsonPointer,
    opts: TemplateOptions
): JsonSchema | false {
    // invalid schema
    if (getTypeOf(schema) !== "object") {
        return Object.assign({ pointer }, schema);
    }
    // return if reached recursion limit
    if (shouldResolveRef(schema, pointer) === false && data == null) {
        return false;
    }

    // resolve $ref and copy schema
    let templateSchema = copy(resolveRef(draft, schema, pointer));

    // @feature anyOf
    if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
        // test if we may resolve
        if (shouldResolveRef(schema.anyOf[0], `${pointer}/anyOf/0`)) {
            const resolvedAnyOf = resolveRef(draft, schema.anyOf[0], `${pointer}/anyOf/0`);
            templateSchema = merge(templateSchema, resolvedAnyOf);
            // add pointer return-value, if any
            templateSchema.pointer = schema.anyOf[0].$ref || templateSchema.pointer;
        }
        delete templateSchema.anyOf;
    }

    // @feature allOf
    if (Array.isArray(schema.allOf)) {
        const mayResolve = schema.allOf
            .map((allOf, index) => shouldResolveRef(allOf, `${pointer}/allOf/${index}`))
            .reduceRight((next, before) => next && before, true);

        if (mayResolve) {
            // before merging all-of, we need to resolve all if-then-else statesments
            // we need to udpate data on the way to trigger if-then-else schemas sequentially.
            // Note that this will make if-then-else order-dependent
            const allOf = [];
            let extendedData = copy(data);
            for (let i = 0; i < schema.allOf.length; i += 1) {
                allOf.push(resolveSchema(draft, schema.allOf[i], extendedData));
                extendedData = getTemplate(draft, extendedData, { type: schema.type, ...allOf[i] }, `${pointer}/allOf/${i}`, opts);
            }

            const resolvedSchema = mergeAllOfSchema(draft, { allOf });
            if (resolvedSchema) {
                templateSchema = mergeSchema(templateSchema, resolvedSchema);
            }
        }
    }

    templateSchema.pointer = templateSchema.pointer || schema.$ref || pointer;
    return templateSchema;
}

const isJsonSchema = (template: unknown): template is JsonSchema =>
    template && typeof template === "object";

/**
 * Create data object matching the given schema
 *
 * @param draft - json schema draft
 * @param [data] - optional template data
 * @param [schema] - json schema, defaults to rootSchema
 * @return created template data
 */
function getTemplate(
    draft: Draft,
    data?: unknown,
    _schema?: JsonSchema,
    pointer?: JsonPointer,
    opts?: TemplateOptions
) {
    if (_schema == null) {
        throw new Error(`getTemplate: missing schema for data: ${JSON.stringify(data)}`);
    }
    if (pointer == null) {
        throw new Error("Missing pointer");
    }

    // resolve $ref references, allOf and first anyOf definitions
    let schema = createTemplateSchema(draft, _schema, data, pointer, opts);
    if (!isJsonSchema(schema)) {
        return undefined;
    }
    pointer = schema.pointer;

    if (schema?.const) {
        return schema.const;
    }

    // @feature oneOf
    if (Array.isArray(schema.oneOf)) {
        if (isEmpty(data)) {
            const type =
                schema.oneOf[0].type ||
                schema.type ||
                (schema.const && typeof schema.const) ||
                getTypeOf(data);
            schema = { ...schema.oneOf[0], type };
        } else {
            // find correct schema for data
            const resolvedSchema = resolveOneOfFuzzy(draft, data, schema);
            if (isJsonError(resolvedSchema)) {
                if (data != null && opts.removeInvalidData !== true) {
                    return data;
                }
                // override
                schema = schema.oneOf[0];
                data = undefined;
            } else {
                resolvedSchema.type = resolvedSchema.type ?? schema.type;
                schema = resolvedSchema;
            }
        }
    }

    // @todo Array.isArray(schema.type)
    // -> hasDefault? return
    // if not -> pick first types
    if (!isJsonSchema(schema) || schema.type == null) {
        return undefined;
    }

    // @attention - very special case to support file instances
    if (data instanceof File) {
        return data;
    }

    const type = Array.isArray(schema.type)
        ? selectType(schema.type, data, schema.default)
        : schema.type;

    // reset invalid type
    const javascriptTypeOfData = getTypeOf(data);


    if (
        data != null &&
        javascriptTypeOfData !== type &&
        !(javascriptTypeOfData === "number" && type === "integer")
    ) {
        data = convertValue(type, data);
    }

    if (TYPE[type] == null) {
        // in case we could not resolve the type
        // (schema-type could not be resolved and returned an error)
        if (opts.removeInvalidData) {
            return undefined;
        }
        return data;
    }

    const templateData = TYPE[type](draft, schema, data, pointer, opts);
    return templateData;
}

function selectType(types: string[], data: unknown, defaultValue: unknown) {
    if (data == undefined) {
        if (defaultValue != null) {
            const defaultType = getTypeOf(defaultValue);
            if (types.includes(defaultType)) {
                return defaultType;
            }
        }
        return types[0];
    }
    const dataType = getTypeOf(data);
    if (types.includes(dataType)) {
        return dataType;
    }
    return types[0];
}

const TYPE: Record<
    string,
    (
        draft: Draft,
        schema: JsonSchema,
        data: unknown,
        pointer: JsonPointer,
        opts: TemplateOptions
    ) => unknown
> = {
    null: (draft, schema, data) => getDefault(schema, data, null),
    string: (draft, schema, data) => getDefault(schema, data, ""),
    number: (draft, schema, data) => getDefault(schema, data, 0),
    integer: (draft, schema, data) => getDefault(schema, data, 0),
    boolean: (draft, schema, data) => getDefault(schema, data, false),
    object: (
        draft,
        schema,
        data: Record<string, unknown> | undefined,
        pointer: JsonPointer,
        opts: TemplateOptions
    ) => {
        const template = schema.default === undefined ? {} : schema.default;
        const d: Record<string, unknown> = {}; // do not assign data here, to keep ordering from json-schema
        const required = (opts.extendDefaults === false && schema.default !== undefined) ? [] : (schema.required ?? []);

        if (schema.properties) {
            Object.keys(schema.properties).forEach((key) => {
                const value = data == null || data[key] == null ? template[key] : data[key];
                const isRequired = required.includes(key);

                // Omit adding a property if it is not required or optional props should be added
                if (value != null || isRequired || opts.addOptionalProps) {
                    d[key] = getTemplate(
                        draft,
                        value,
                        schema.properties[key],
                        `${pointer}/properties/${key}`,
                        opts
                    );
                }
            });
        }

        // @feature dependencies
        // has to be done after resolving properties so dependency may trigger
        let dependenciesSchema = resolveDependencies(draft, schema, d);
        if (dependenciesSchema) {
            dependenciesSchema = mergeSchema(schema, dependenciesSchema);
            delete dependenciesSchema.dependencies;
            const dependencyData = getTemplate(
                draft,
                data,
                dependenciesSchema,
                `${pointer}/dependencies`,
                opts
            );
            Object.assign(d, dependencyData);
        }

        if (data) {
            if (
                opts.removeInvalidData === true &&
                (schema.additionalProperties === false ||
                    getTypeOf(schema.additionalProperties) === "object")
            ) {
                if (getTypeOf(schema.additionalProperties) === "object") {
                    Object.keys(data).forEach((key) => {
                        if (d[key] == null) {
                            // merge valid missing data (additionals) to resulting object
                            if (draft.isValid(data[key], schema.additionalProperties)) {
                                d[key] = data[key];
                            }
                        }
                    });
                }
            } else {
                // merge any missing data (additionals) to resulting object
                Object.keys(data).forEach((key) => d[key] == null && (d[key] = data[key]));
            }
        }

        // @feature if-then-else
        const ifSchema = resolveIfSchema(draft, schema, d);
        if (ifSchema) {
            const additionalData = getTemplate(
                draft,
                d,
                { type: "object", ...ifSchema },
                pointer,
                opts
            );
            Object.assign(d, additionalData);
        }

        // returns object, which is ordered by json-schema
        return d;
    },
    // build array type of items, ignores additionalItems
    array: (
        draft: Draft,
        schema: JsonSchema,
        data: unknown[],
        pointer: JsonPointer,
        opts: TemplateOptions
    ) => {
        if (schema.items == null) {
            return data || []; // items are undefined
        }

        const template = schema.default === undefined ? [] : schema.default;
        const d: unknown[] = data || template;
        const minItems = (opts.extendDefaults === false && schema.default !== undefined) ? 0 : (schema.minItems || 0);

        // build defined set of items
        if (Array.isArray(schema.items)) {
            for (let i = 0, l = Math.max(minItems ?? 0, schema.items?.length ?? 0); i < l; i += 1) {
                d[i] = getTemplate(
                    draft,
                    d[i] == null ? template[i] : d[i],
                    schema.items[i],
                    `${pointer}/items/${i}`,
                    opts
                );
            }
            return d;
        }

        // abort if the schema is invalid
        if (getTypeOf(schema.items) !== "object") {
            return d;
        }

        // resolve allOf and first anyOf definition
        const templateSchema = createTemplateSchema(draft, schema.items, data, pointer, opts);
        if (templateSchema === false) {
            return d;
        }
        pointer = templateSchema.pointer || pointer;

        // build data for first oneOf-schema
        if (templateSchema.oneOf && d.length === 0) {
            const oneOfSchema = templateSchema.oneOf[0];
            for (let i = 0; i < minItems; i += 1) {
                d[i] = getTemplate(
                    draft,
                    d[i] == null ? template[i] : d[i],
                    oneOfSchema,
                    `${pointer}/oneOf/0`,
                    opts
                );
            }
            return d;
        }

        // complete data selecting correct oneOf-schema
        if (templateSchema.oneOf && d.length > 0) {
            const itemCount = Math.max(minItems, d.length);
            for (let i = 0; i < itemCount; i += 1) {
                let value = d[i] == null ? template[i] : d[i];
                let one = resolveOneOfFuzzy(draft, value, templateSchema);

                if (one == null || isJsonError(one)) {
                    // schema could not be resolved or data is invalid
                    if (value != null && opts.removeInvalidData !== true) {
                        // keep invalid value
                        d[i] = value;
                    } else {
                        // replace invalid value
                        value = undefined;
                        one = templateSchema.oneOf[0];
                        d[i] = getTemplate(draft, value, one, `${pointer}/oneOf/${i}`, opts);
                    }
                } else {
                    // schema is valid
                    d[i] = getTemplate(draft, value, one, `${pointer}/oneOf/${i}`, opts);
                }
            }
            return d;
        }

        // build data from items-definition
        if (templateSchema.type) {
            for (let i = 0, l = Math.max(minItems, d.length); i < l; i += 1) {
                d[i] = getTemplate(
                    draft,
                    d[i] == null ? template[i] : d[i],
                    templateSchema,
                    `${pointer}/items`,
                    opts
                );
            }
            return d;
        }

        return d;
    }
};

function getDefault(schema: JsonSchema, templateValue: any, initValue: any) {
    if (templateValue != null) {
        return templateValue;
    } else if (schema.const) {
        return schema.const;
    } else if (schema.default === undefined && Array.isArray(schema.enum)) {
        return schema.enum[0];
    } else if (schema.default === undefined) {
        return initValue;
    }
    return schema.default;
}

export default (
    draft: Draft,
    data?: any,
    schema: JsonSchema = draft.rootSchema,
    opts?: TemplateOptions
) => {
    cache = {};
    if (opts) {
        return getTemplate(draft, data, schema, "#", { ...defaultOptions, ...opts });
    }
    return getTemplate(draft, data, schema, "#", defaultOptions);
};
