package io.apicurio.registry.rules.compatibility.jsonschema.diff;

import io.apicurio.registry.rules.compatibility.jsonschema.wrapper.SchemaWrapper;
import org.everit.json.schema.Schema;

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;

import static io.apicurio.registry.rules.compatibility.jsonschema.wrapper.WrapUtil.wrap;
import static java.util.Objects.requireNonNull;

public class DiffUtil {

    /**
     * added/removed
     *
     * @return true if both objects are present
     */
    public static boolean diffAddedRemoved(DiffContext ctx, Object original, Object updated,
            DiffType addedType, DiffType removedType) {
        if (original == null && updated != null) {
            ctx.addDifference(addedType, original, updated);
        } else if (original != null && updated == null) {
            ctx.addDifference(removedType, original, updated);
        } else {
            return original != null;
        }
        return false;
    }

    public static <T> void diffSetChanged(DiffContext ctx, Set<T> original, Set<T> updated,
            DiffType addedType, DiffType removedType, DiffType changedType, DiffType addedMemberType,
            DiffType removedMemberType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType)) {
            boolean changed = false;
            Set<?> copyUpdated = new HashSet<>(updated);
            for (Object originalMember : original) {
                if (updated.contains(originalMember)) {
                    // OK
                    copyUpdated.remove(originalMember);
                } else {
                    ctx.addDifference(removedMemberType, originalMember, null);
                    changed = true;
                }
            }
            for (Object updatedMemberRemaining : copyUpdated) {
                ctx.addDifference(addedMemberType, null, updatedMemberRemaining);
                changed = true;
            }
            if (changed)
                ctx.addDifference(changedType, original, updated);
        }
    }

    /**
     * @return true if both objects are present
     */
    public static boolean diffSubschemaAddedRemoved(DiffContext ctx, Object original, Object updated,
            DiffType addedType, DiffType removedType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType)) {
            return true;
        }
        return false;
    }

    /**
     * added/removed/increased/decreased
     *
     * @return true if the integers are defined and equal
     */
    public static boolean diffInteger(DiffContext ctx, Integer original, Integer updated, DiffType addedType,
            DiffType removedType, DiffType increasedType, DiffType decreasedType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType)) {
            if (original < updated) {
                ctx.addDifference(increasedType, original, updated);
            } else if (original > updated) {
                ctx.addDifference(decreasedType, original, updated);
            } else {
                return true;
            }
        }
        return false;
    }

    /**
     * added/removed/increased/decreased
     *
     * @return true if the numbers are the same
     */
    public static boolean diffNumber(DiffContext ctx, Number original, Number updated, DiffType addedType,
            DiffType removedType, DiffType increasedType, DiffType decreasedType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType)) {
            BigDecimal o = new BigDecimal(original.toString()); // Not pretty but it works:/
            BigDecimal u = new BigDecimal(updated.toString());
            if (o.compareTo(u) < 0) {
                ctx.addDifference(increasedType, original, updated);
            } else if (o.compareTo(u) > 0) {
                ctx.addDifference(decreasedType, original, updated);
            } else {
                return true;
            }
        }
        return false;
    }

    public static void diffNumberOriginalMultipleOfUpdated(DiffContext ctx, Number original, Number updated,
            DiffType multipleOfType, DiffType notMultipleOfType) {
        requireNonNull(original);
        requireNonNull(updated);
        BigDecimal o = new BigDecimal(original.toString()); // Not pretty but it works:/
        BigDecimal u = new BigDecimal(updated.toString());
        if (o.remainder(u).compareTo(BigDecimal.ZERO) == 0) {
            ctx.addDifference(multipleOfType, original, updated);
        } else {
            ctx.addDifference(notMultipleOfType, original, updated);
        }

    }

    /**
     *
     */
    public static boolean diffBooleanTransition(DiffContext ctx, Boolean original, Boolean updated,
            Boolean defaultValue, DiffType changeFalseToTrue, DiffType changeTrueToFalse,
            DiffType unchanged) {
        if (original == null)
            original = defaultValue;
        if (updated == null)
            updated = defaultValue;
        if (original && !updated) {
            ctx.addDifference(changeTrueToFalse, original, updated);
        } else if (!original && updated) {
            ctx.addDifference(changeFalseToTrue, original, updated);
        } else {
            ctx.addDifference(unchanged, original, updated);
            return true;
        }
        return false;
    }

    /**
     * added/removed/changed (using equals)
     */
    public static void diffObject(DiffContext ctx, Object original, Object updated, DiffType addedType,
            DiffType removedType, DiffType changedType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType) && !original.equals(updated)) {
            ctx.addDifference(changedType, original, updated);
        }
    }

    /**
     * added/removed/changed (using equals), with a default value specified
     */
    public static void diffObjectDefault(DiffContext ctx, Object original, Object updated,
            Object defaultValue, DiffType addedType, DiffType removedType, DiffType changedType) {
        if (Objects.equals(defaultValue, original))
            original = null;
        if (Objects.equals(defaultValue, updated))
            updated = null;
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType) && !original.equals(updated)) {
            ctx.addDifference(changedType, original, updated);
        }
    }

    /**
     * added/removed/changed (using ==)
     *
     * @return true if they are equal
     */
    public static boolean diffObjectIdentity(DiffContext ctx, Object original, Object updated, Object target,
            DiffType addedType, DiffType removedType, DiffType extendedType, DiffType narrowedType,
            DiffType changedType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType) && original != updated) {
            if (updated == target) {
                ctx.addDifference(extendedType, original, updated);
            } else if (original == target) {
                ctx.addDifference(narrowedType, original, updated);
            } else {
                ctx.addDifference(changedType, original, updated);
            }
            return false;
        }
        return true;
    }

    public static void diffSubSchemasAdded(DiffContext ctx, List<SchemaWrapper> addedSchemas,
            boolean originalPermitsAdditional, SchemaWrapper originalSchemaOfAdditional,
            boolean updatedPermitsAdditional, DiffType extendedType, DiffType narrowedType,
            DiffType changedType) {
        if (!originalPermitsAdditional) {
            // original schema: additional = false
            ctx.addDifference(extendedType, null, addedSchemas);
        } else {
            if (originalSchemaOfAdditional == null) {
                // original schema: additional = true
                ctx.addDifference(narrowedType, true, addedSchemas);
            } else {
                // original schema: additional = schema
                if (!updatedPermitsAdditional
                        && areListOfSchemasCompatible(ctx, addedSchemas, originalSchemaOfAdditional, false)) {
                    ctx.addDifference(narrowedType, originalSchemaOfAdditional, addedSchemas);
                } else if (updatedPermitsAdditional
                        && areListOfSchemasCompatible(ctx, addedSchemas, originalSchemaOfAdditional, true)) {
                    ctx.addDifference(extendedType, originalSchemaOfAdditional, addedSchemas);
                } else {
                    ctx.addDifference(changedType, originalSchemaOfAdditional, addedSchemas);
                }
            }
        }
    }

    public static void diffSubSchemasRemoved(DiffContext ctx, List<SchemaWrapper> removedSchemas,
            boolean updatedPermitsAdditional, SchemaWrapper updatedSchemaOfAdditional,
            boolean originalPermitsAdditional, DiffType narrowedType,
            DiffType narrowedTypeCompatibleWithAdditional, DiffType extendedType, DiffType changedType) {
        if (!updatedPermitsAdditional) {
            // Updated schema does not permit additional properties, by removing a property schema
            // we are narrowing the property schemas (not backwards compatible).
            ctx.addDifference(narrowedType, removedSchemas, null);
        } else {
            if (updatedSchemaOfAdditional == null) {
                // Updated schema of additional properties accepts anything, removing the property
                // is backwards compatible (we're extending the accepted properties).
                ctx.addDifference(extendedType, removedSchemas, true);
            } else {
                // We have a specific updated schema of additional properties,
                // which is checked separately.
                if (areListOfSchemasCompatible(ctx, removedSchemas, updatedSchemaOfAdditional, true)) {
                    // Removed property is compatible with updated additional properties schema,
                    // which is backwards compatible.
                    ctx.addDifference(narrowedTypeCompatibleWithAdditional, removedSchemas,
                            updatedSchemaOfAdditional);
                } else {
                    ctx.addDifference(changedType, removedSchemas, updatedSchemaOfAdditional);
                }
            }
        }
    }

    public static void diffSchemaOrTrue(DiffContext ctx, Schema original, Schema updated, DiffType bothType,
            DiffType extendedType, DiffType narrowedType, DiffType noneType) {
        if (original != null && updated == null) {
            // schema => true
            ctx.addDifference(extendedType, original, updated);
        } else if (original == null && updated != null) {
            // true => schema
            ctx.addDifference(narrowedType, original, updated);
        } else if (updated != null && original != null) {
            // schema => schema
            compareSchemaWhenExist(ctx, original, updated, bothType, extendedType, narrowedType, noneType);
        }
    }

    public static void compareSchema(DiffContext ctx, Schema original, Schema updated, DiffType addedType,
            DiffType removedType, DiffType bothType, DiffType backwardNotForwardType,
            DiffType forwardNotBackwardType, DiffType noneType) {
        if (diffAddedRemoved(ctx, original, updated, addedType, removedType)) {
            compareSchemaWhenExist(ctx, original, updated, bothType, backwardNotForwardType,
                    forwardNotBackwardType, noneType);
        }
    }

    public static void compareSchemaWhenExist(DiffContext ctx, Schema original, Schema updated,
            DiffType bothType, DiffType backwardType, DiffType forwardType, DiffType noneType) {
        boolean backward = isSchemaCompatible(ctx, original, updated, true);
        boolean forward = isSchemaCompatible(ctx, original, updated, false);

        if (backward && forward) {
            ctx.addDifference(bothType, original, updated);
        }
        if (backward && !forward) {
            ctx.addDifference(backwardType, original, updated);
        }
        if (!backward && forward) {
            ctx.addDifference(forwardType, original, updated);
        }
        if (!backward && !forward) {
            ctx.addDifference(noneType, original, updated);
        }
    }

    public static boolean areListOfSchemasCompatible(DiffContext ctx, List<SchemaWrapper> itemSchemas,
            SchemaWrapper additionalSchema, boolean backward) {
        for (SchemaWrapper itemSchema : itemSchemas) {
            if (!isSchemaCompatible(ctx, itemSchema.getWrapped(), additionalSchema.getWrapped(), backward)) {
                return false;
            }
        }
        return true;
    }

    public static boolean isSchemaCompatible(DiffContext ctx, Schema original, Schema updated,
            boolean backward) {
        DiffContext rootCtx = DiffContext.createRootContext("", ctx.visited);
        if (backward) {
            new SchemaDiffVisitor(rootCtx, original).visit(wrap(updated));
        } else {
            new SchemaDiffVisitor(rootCtx, updated).visit(wrap(original));
        }
        return rootCtx.foundAllDifferencesAreCompatible();
    }

    /**
     * Use getter and return null if there is an exception.
     */
    public static <T> T getExceptionally(DiffContext ctx, Supplier<T> getter) {
        try {
            return getter.get();
        } catch (Exception ex) {
            ctx.log("Caught exception when getting exceptionally: " + ex + ". Returning null.");
            return null;
        }
    }
}
