import {
    CMSType,
    EntityReference,
    GeoPoint,
    ResolvedArrayProperty,
    ResolvedMapProperty,
    ResolvedProperties,
    ResolvedProperty
} from "../types";
import * as yup from "yup";
import { AnySchema, ArraySchema, BooleanSchema, DateSchema, NumberSchema, ObjectSchema, StringSchema } from "yup";
import { enumToObjectEntries, getValueInPath, hydrateRegExp, isPropertyBuilder } from "../util";

// Add custom unique function for array values
declare module "yup" {
    // tslint:disable-next-line
    interface ArraySchema<T> {
        uniqueInArray(mapper: (a: T) => T, message: string): ArraySchema<T>;
    }
}
yup.addMethod(yup.array, "uniqueInArray", function (
    mapper = (a: any) => a,
    message: string
) {
    return this.test("uniqueInArray", message, values => {
        return !values || values.length === new Set(values.map(mapper)).size;
    });
});

export type CustomFieldValidator = (props: {
    name: string,
    value: any,
    property: ResolvedProperty,
    entityId?: string,
    parentProperty?: ResolvedMapProperty | ResolvedArrayProperty,
}) => Promise<boolean>;

interface PropertyContext<T extends CMSType> {
    property: ResolvedProperty<T>,
    parentProperty?: ResolvedMapProperty | ResolvedArrayProperty,
    entityId: string,
    customFieldValidator?: CustomFieldValidator,
    name?: any
}

export function getYupEntitySchema<M extends Record<string, any>>(
    entityId: string,
    properties: ResolvedProperties<M>,
    customFieldValidator?: CustomFieldValidator): ObjectSchema<any> {
    const objectSchema: any = {};
    Object.entries(properties as Record<string, ResolvedProperty>)
        .forEach(([name, property]) => {
            objectSchema[name] = mapPropertyToYup({
                property: property as ResolvedProperty<any>,
                customFieldValidator,
                name,
                entityId
            });
        });
    return yup.object().shape(objectSchema);
}

export function mapPropertyToYup<T extends CMSType>(propertyContext: PropertyContext<T>): AnySchema<unknown> {

    const property = propertyContext.property;
    if (isPropertyBuilder(property)) {
        console.error("Error in property", propertyContext);
        throw Error("Trying to create a yup mapping from a property builder. Please use resolved properties only");
    }

    if (property.dataType === "string") {
        return getYupStringSchema(propertyContext as PropertyContext<string>);
    } else if (property.dataType === "number") {
        return getYupNumberSchema(propertyContext as PropertyContext<number>);
    } else if (property.dataType === "boolean") {
        return getYupBooleanSchema(propertyContext as PropertyContext<boolean>);
    } else if (property.dataType === "map") {
        return getYupMapObjectSchema(propertyContext as PropertyContext<object>);
    } else if (property.dataType === "array") {
        return getYupArraySchema(propertyContext as PropertyContext<any[]>);
    } else if (property.dataType === "date") {
        return getYupDateSchema(propertyContext as PropertyContext<Date>);
    } else if (property.dataType === "geopoint") {
        return getYupGeoPointSchema(propertyContext as PropertyContext<GeoPoint>);
    } else if (property.dataType === "reference") {
        return getYupReferenceSchema(propertyContext as PropertyContext<EntityReference>);
    }
    console.error("Unsupported data type in yup mapping", property)
    throw Error("Unsupported data type in yup mapping");
}

export function getYupMapObjectSchema({
                                          property,
                                          entityId,
                                          customFieldValidator,
                                          name
                                      }: PropertyContext<Record<string, any>>): ObjectSchema<any> {
    const objectSchema: any = {};
    const validation = property.validation;
    if (property.properties)
        Object.entries(property.properties).forEach(([childName, childProperty]: [string, ResolvedProperty]) => {
            objectSchema[childName] = mapPropertyToYup<any>({
                property: childProperty,
                parentProperty: property as ResolvedMapProperty,
                customFieldValidator,
                name: `${name}[${childName}]`,
                entityId
            });
        });

    const shape = yup.object().shape(objectSchema);
    if (validation?.required) {
        return shape.required(validation?.requiredMessage ? validation.requiredMessage : "Required").nullable(true);
    }
    return yup.object().shape(shape.fields).default(undefined).notRequired().nullable(true);
}

function getYupStringSchema({
                                property,
                                parentProperty,
                                customFieldValidator,
                                name,
                                entityId
                            }: PropertyContext<string>): StringSchema {
    let collection: StringSchema<any> = yup.string();
    const validation = property.validation;
    if (property.enumValues) {
        if (validation?.required)
            collection = collection.required(validation?.requiredMessage ? validation.requiredMessage : "Required");
        const entries = enumToObjectEntries(property.enumValues);
        collection = collection.oneOf(
            (validation?.required ? entries : [...entries, null])
                .map((enumValueConfig) => enumValueConfig?.id ?? null)
        ).nullable(true);
    }
    if (validation) {
        collection = validation.required
            ? collection.required(validation?.requiredMessage ? validation.requiredMessage : "Required").nullable(true)
            : collection.notRequired().nullable(true);
        if (validation.unique && customFieldValidator && name)
            collection = collection.test("unique", "This value already exists and should be unique",
                (value, context) =>
                    customFieldValidator({
                        name,
                        property,
                        parentProperty,
                        value,
                        entityId
                    }));
        if (validation.min || validation.min === 0) collection = collection.min(validation.min, `${property.name} must be min ${validation.min} characters long`);
        if (validation.max || validation.max === 0) collection = collection.max(validation.max, `${property.name} must be max ${validation.max} characters long`);
        if (validation.matches) {
            const regExp = typeof validation.matches === "string" ? hydrateRegExp(validation.matches) : validation.matches;
            if (regExp) {
                collection = collection.matches(regExp, validation.matchesMessage ? { message: validation.matchesMessage } : undefined)
            }
        }
        if (validation.trim) collection = collection.trim();
        if (validation.lowercase) collection = collection.lowercase();
        if (validation.uppercase) collection = collection.uppercase();
        if (property.email) collection = collection.email(`${property.name} must be an email`);
        if (property.url) {
            if (!property.storage || property.storage?.storeUrl) {
                collection = collection.url(`${property.name} must be a url`);
            } else {
                console.warn(`Property ${property.name} has a url validation but its storage configuration is not set to store urls`);
            }
        }
    } else {
        collection = collection.notRequired().nullable(true);
    }
    return collection;
}

function getYupNumberSchema({
                                property,
                                parentProperty,
                                customFieldValidator,
                                name,
                                entityId
                            }: PropertyContext<number>): NumberSchema {
    const validation = property.validation;
    let collection: NumberSchema<any> = yup.number().typeError("Must be a number");
    if (validation) {
        collection = validation.required
            ? collection.required(validation.requiredMessage ? validation.requiredMessage : "Required").nullable(true)
            : collection.notRequired().nullable(true);
        if (validation.unique && customFieldValidator && name)
            collection = collection.test("unique",
                "This value already exists and should be unique",
                (value) => customFieldValidator({
                    name,
                    property,
                    parentProperty,
                    value,
                    entityId
                }));
        if (validation.min || validation.min === 0) collection = collection.min(validation.min, `${property.name} must be higher or equal to ${validation.min}`);
        if (validation.max || validation.max === 0) collection = collection.max(validation.max, `${property.name} must be lower or equal to ${validation.max}`);
        if (validation.lessThan || validation.lessThan === 0) collection = collection.lessThan(validation.lessThan, `${property.name} must be higher than ${validation.lessThan}`);
        if (validation.moreThan || validation.moreThan === 0) collection = collection.moreThan(validation.moreThan, `${property.name} must be lower than ${validation.moreThan}`);
        if (validation.positive) collection = collection.positive(`${property.name} must be positive`);
        if (validation.negative) collection = collection.negative(`${property.name} must be negative`);
        if (validation.integer) collection = collection.integer(`${property.name} must be an integer`);
    } else {
        collection = collection.notRequired().nullable(true);
    }
    return collection;
}

function getYupGeoPointSchema({
                                  property,
                                  parentProperty,
                                  customFieldValidator,
                                  name,
                                  entityId
                              }: PropertyContext<GeoPoint>): AnySchema {
    let collection: ObjectSchema<any> = yup.object();
    const validation = property.validation;
    if (validation?.unique && customFieldValidator && name)
        collection = collection.test("unique",
            "This value already exists and should be unique",
            (value) => customFieldValidator({
                name,
                property,
                parentProperty,
                value,
                entityId
            }));
    if (validation?.required) {
        collection = collection.required(validation.requiredMessage).nullable(true);
    } else {
        collection = collection.notRequired().nullable(true);
    }
    return collection;
}

function getYupDateSchema({
                              property,
                              parentProperty,
                              customFieldValidator,
                              name,
                              entityId
                          }: PropertyContext<Date>): AnySchema | DateSchema {
    if (property.autoValue) {
        return yup.object().nullable();
    }
    let collection: DateSchema<any> = yup.date();
    const validation = property.validation;
    if (validation) {
        collection = validation.required
            ? collection.required(validation?.requiredMessage ? validation.requiredMessage : "Required")
            : collection.notRequired();
        if (validation.unique && customFieldValidator && name)
            collection = collection.test("unique",
                "This value already exists and should be unique",
                (value) => customFieldValidator({
                    name,
                    property,
                    parentProperty,
                    value,
                    entityId
                }));
        if (validation.min) collection = collection.min(validation.min, `${property.name} must be after ${validation.min}`);
        if (validation.max) collection = collection.max(validation.max, `${property.name} must be before ${validation.min}`);
    } else {
        collection = collection.notRequired();
    }
    return collection
        .transform((v: any) => (v instanceof Date ? v : null))
        .nullable();
}

function getYupReferenceSchema({
                                   property,
                                   parentProperty,
                                   customFieldValidator,
                                   name,
                                   entityId
                               }: PropertyContext<EntityReference>): AnySchema {
    let collection: ObjectSchema<any> = yup.object();
    const validation = property.validation;
    if (validation) {
        collection = validation.required
            ? collection.required(validation?.requiredMessage ? validation.requiredMessage : "Required").nullable(true)
            : collection.notRequired().nullable(true);
        if (validation.unique && customFieldValidator && name)
            collection = collection.test("unique",
                "This value already exists and should be unique",
                (value) => customFieldValidator({
                    name,
                    property,
                    parentProperty,
                    value,
                    entityId
                }));
    } else {
        collection = collection.notRequired().nullable(true);
    }
    return collection;
}

function getYupBooleanSchema({
                                 property,
                                 parentProperty,
                                 customFieldValidator,
                                 name,
                                 entityId
                             }: PropertyContext<boolean>): BooleanSchema {
    let collection: BooleanSchema<any> = yup.boolean();
    const validation = property.validation;
    if (validation) {
        collection = validation.required
            ? collection.required(validation?.requiredMessage ? validation.requiredMessage : "Required").nullable(true)
            : collection.notRequired().nullable(true);
        if (validation.unique && customFieldValidator && name)
            collection = collection.test("unique",
                "This value already exists and should be unique",
                (value) => customFieldValidator({
                    name,
                    property,
                    parentProperty,
                    value,
                    entityId
                }));
    } else {
        collection = collection.notRequired().nullable(true);
    }
    return collection;
}

function hasUniqueInArrayModifier(property: ResolvedProperty): boolean | [string, ResolvedProperty][] {
    if (property.validation?.uniqueInArray) {
        return true;
    } else if (property.dataType === "map" && property.properties) {
        return Object.entries(property.properties)
            .filter(([key, childProperty]) => childProperty.validation?.uniqueInArray);
    }
    return false;
}

function getYupArraySchema({
                               property,
                               parentProperty,
                               customFieldValidator,
                               name,
                               entityId
                           }: PropertyContext<any[]>): AnySchema<any> {

    let arraySchema: ArraySchema<any> = yup.array();

    if (property.of) {
        if (Array.isArray(property.of)) {
            const yupProperties = (property.of as ResolvedProperty[]).map((p, index) => ({
                [`${name}[${index}]`]: mapPropertyToYup({
                    property: p as ResolvedProperty<any>,
                    parentProperty: property,
                    entityId
                })
            })).reduce((a, b) => ({ ...a, ...b }), {});
            return yup.array().of(
                yup.mixed().test(
                    "Dynamic object validation",
                    "Dynamic object validation error",
                    (object, context) => {
                        const yupProperty = getValueInPath(yupProperties, context.path);
                        return yupProperty.validate(object);
                    }
                )
            );
        } else {
            arraySchema = arraySchema.of(mapPropertyToYup({
                property: property.of,
                parentProperty: property,
                entityId
            }));
            const arrayUniqueFields = hasUniqueInArrayModifier(property.of);
            if (arrayUniqueFields) {
                if (typeof arrayUniqueFields === "boolean") {
                    arraySchema = arraySchema.uniqueInArray(v => v, `${property.name} should have unique values within the array`);
                } else if (Array.isArray(arrayUniqueFields)) {
                    arrayUniqueFields.forEach(([name, childProperty]) => {
                            arraySchema = arraySchema.uniqueInArray(v => v && v[name], `${property.name} → ${childProperty.name ?? name}: should have unique values within the array`);
                        }
                    );
                }
            }
        }
    }
    const validation = property.validation;

    if (validation) {
        arraySchema = validation.required
            ? arraySchema.required(validation?.requiredMessage ? validation.requiredMessage : "Required").nullable(true)
            : arraySchema.notRequired().nullable(true);
        if (validation.min || validation.min === 0) arraySchema = arraySchema.min(validation.min, `${property.name} should be min ${validation.min} entries long`);
        if (validation.max) arraySchema = arraySchema.max(validation.max, `${property.name} should be max ${validation.max} entries long`);
    } else {
        arraySchema = arraySchema.notRequired().nullable(true);
    }
    return arraySchema;
}
