/*
 * Copyright 2020 VicTools.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.github.victools.jsonschema.generator;

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.impl.AttributeCollector;
import com.github.victools.jsonschema.generator.impl.DefinitionKey;
import com.github.victools.jsonschema.generator.impl.SchemaCleanUpUtils;
import com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl;
import com.github.victools.jsonschema.generator.naming.CleanSchemaDefinitionNamingStrategy;
import com.github.victools.jsonschema.generator.naming.DefaultSchemaDefinitionNamingStrategy;
import com.github.victools.jsonschema.generator.naming.SchemaDefinitionNamingStrategy;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
 * Builder for a single schema being generated.
 */
public class SchemaBuilder {

    /**
     * Generate an {@link ObjectNode} containing the JSON Schema representation of the given type.
     *
     * @param config configuration to be applied
     * @param typeContext type resolution/introspection context to be used during schema generation
     * @param mainTargetType type for which to generate the JSON Schema
     * @param typeParameters optional type parameters (in case of the {@code mainTargetType} being a parameterised type)
     * @return generated JSON Schema
     */
    static ObjectNode createSingleTypeSchema(SchemaGeneratorConfig config, TypeContext typeContext,
            Type mainTargetType, Type... typeParameters) {
        SchemaBuilder instance = new SchemaBuilder(config, typeContext);
        return instance.createSchemaForSingleType(mainTargetType, typeParameters);
    }

    /**
     * Initialise a multi-type schema builder.
     *
     * @param config configuration to be applied
     * @param typeContext type resolution/introspection context to be used during schema generation
     * @return builder instance
     * @see #createSchemaReference(Type, Type...) : adding a single type to the builder instance
     * @see #collectDefinitions(String) : generate an {@link ObjectNode} listing the common schema definitions
     */
    static SchemaBuilder forMultipleTypes(SchemaGeneratorConfig config, TypeContext typeContext) {
        return new SchemaBuilder(config, typeContext);
    }

    private final SchemaGeneratorConfig config;
    private final TypeContext typeContext;
    private final SchemaGenerationContextImpl generationContext;
    private final List<ObjectNode> schemaNodes;
    private final CleanSchemaDefinitionNamingStrategy definitionNamingStrategy;

    /**
     * Constructor.
     *
     * @param config configuration to be applied
     * @param typeContext type resolution/introspection context to be used during schema generation
     */
    SchemaBuilder(SchemaGeneratorConfig config, TypeContext typeContext) {
        this.config = config;
        this.typeContext = typeContext;
        this.generationContext = new SchemaGenerationContextImpl(this.config, this.typeContext);
        this.schemaNodes = new ArrayList<>();

        SchemaDefinitionNamingStrategy baseNamingStrategy = config.getDefinitionNamingStrategy();
        if (baseNamingStrategy == null) {
            baseNamingStrategy = new DefaultSchemaDefinitionNamingStrategy();
        }
        SchemaCleanUpUtils cleanupUtils = new SchemaCleanUpUtils(config);
        Function<String, String> definitionCleanUpTask = config.shouldUsePlainDefinitionKeys()
                ? cleanupUtils::ensureDefinitionKeyIsPlain
                : cleanupUtils::ensureDefinitionKeyIsUriCompatible;

        this.definitionNamingStrategy = new CleanSchemaDefinitionNamingStrategy(baseNamingStrategy, definitionCleanUpTask);
    }

    /**
     * Generate an {@link ObjectNode} containing the JSON Schema representation of the given type.
     *
     * @param mainTargetType type for which to generate the JSON Schema
     * @param typeParameters optional type parameters (in case of the {@code mainTargetType} being a parameterised type)
     * @return generated JSON Schema
     */
    private ObjectNode createSchemaForSingleType(Type mainTargetType, Type... typeParameters) {
        ResolvedType mainType = this.typeContext.resolve(mainTargetType, typeParameters);
        DefinitionKey mainKey = this.generationContext.parseType(mainType);

        ObjectNode jsonSchemaResult = this.config.createObjectNode();
        if (this.config.shouldIncludeSchemaVersionIndicator()) {
            jsonSchemaResult.put(this.config.getKeyword(SchemaKeyword.TAG_SCHEMA),
                    this.config.getKeyword(SchemaKeyword.TAG_SCHEMA_VALUE));
        }
        boolean createDefinitionForMainSchema = this.config.shouldCreateDefinitionForMainSchema();
        if (createDefinitionForMainSchema) {
            this.generationContext.addReference(mainType, jsonSchemaResult, null, false);
        }
        String definitionsTagName = this.config.getKeyword(SchemaKeyword.TAG_DEFINITIONS);
        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(definitionsTagName, mainKey, this.generationContext);
        if (definitionsNode.size() > 0) {
            jsonSchemaResult.set(definitionsTagName, definitionsNode);
        }
        if (!createDefinitionForMainSchema) {
            ObjectNode mainSchemaNode = this.generationContext.getDefinition(mainKey);
            jsonSchemaResult.setAll(mainSchemaNode);
            this.schemaNodes.add(jsonSchemaResult);
        }
        this.performCleanup();
        return jsonSchemaResult;
    }

    /**
     * Generate an {@link ObjectNode} placeholder for the given type and add all referenced/encountered types to this builder instance.
     * <br>
     * This may be invoked multiple times (even for the same type) until the schema generation is being completed via
     * {@link #collectDefinitions(String)}.
     *
     * @param targetType type for which to generate the JSON Schema placeholder
     * @param typeParameters optional type parameters (in case of the {@code mainTargetType} being a parameterised type)
     * @return JSON Schema placeholder (maybe be empty until {@link #collectDefinitions(String)} is being invoked)
     * @see #collectDefinitions(String)
     */
    public ObjectNode createSchemaReference(Type targetType, Type... typeParameters) {
        ResolvedType resolvedTargetType = this.typeContext.resolve(targetType, typeParameters);
        ObjectNode node = this.generationContext.createDefinitionReference(resolvedTargetType);
        this.schemaNodes.add(node);
        return node;
    }

    /**
     * Completing the schema generation (after {@link #createSchemaReference(Type, Type...)} was invoked for all relevant types) by creating an
     * {@link ObjectNode} containing common schema definitions.
     * <p>
     * The given definition path (e.g. {@code "definitions"}, {@code "$defs"}, {@code "components/schemas"}) will be used in generated {@code "$ref"}
     * values (e.g. {@code "#/definitions/YourType"}, {@code "#/$defs/YourType"}, {@code "#/components/schemas/YourType"}).
     * </p>
     * This should only be invoked once at the very end of the schema generation process.
     *
     * @param designatedDefinitionPath the designated path to the returned definitions node, to be used in generated references
     * @return object node containing common schema definitions
     * @see #createSchemaReference(Type, Type...)
     */
    public ObjectNode collectDefinitions(String designatedDefinitionPath) {
        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(designatedDefinitionPath, null, this.generationContext);
        this.performCleanup();
        return definitionsNode;
    }

    /**
     * Reduce unnecessary structures in the generated schema definitions. Assumption being that this method is being invoked as the very last action
     * of the schema generation.
     *
     * @see SchemaGeneratorConfig#shouldCleanupUnnecessaryAllOfElements()
     * @see SchemaCleanUpUtils#reduceAllOfNodes(List)
     * @see SchemaCleanUpUtils#reduceAnyOfNodes(List)
     */
    private void performCleanup() {
        SchemaCleanUpUtils cleanUpUtils = new SchemaCleanUpUtils(this.config);
        if (this.config.shouldCleanupUnnecessaryAllOfElements()) {
            cleanUpUtils.reduceAllOfNodes(this.schemaNodes);
        }
        cleanUpUtils.reduceAnyOfNodes(this.schemaNodes);
    }

    /**
     * Finalisation Step: collect the entries for the generated schema's "definitions" and ensure that all references are either pointing to the
     * appropriate definition or contain the respective (sub) schema directly inline.
     *
     * @param designatedDefinitionPath designated path to the returned definitions node (to be incorporated in {@link SchemaKeyword#TAG_REF} values)
     * @param mainSchemaKey definition key identifying the main type for which createSchemaReference() was invoked
     * @param generationContext context containing all definitions of (sub) schemas and the list of references to them
     * @return node representing the main schema's "definitions" (may be empty)
     */
    private ObjectNode buildDefinitionsAndResolveReferences(String designatedDefinitionPath, DefinitionKey mainSchemaKey,
            SchemaGenerationContextImpl generationContext) {
        final ObjectNode definitionsNode = this.config.createObjectNode();
        final boolean createDefinitionsForAll = this.config.shouldCreateDefinitionsForAllObjects();
        final boolean inlineAllSchemas = this.config.shouldInlineAllSchemas();

        final AtomicBoolean considerOnlyDirectReferences = new AtomicBoolean(false);
        Predicate<DefinitionKey> shouldProduceDefinition = definitionKey -> {
            if (inlineAllSchemas) {
                return false;
            }
            if (definitionKey.equals(mainSchemaKey)) {
                return true;
            }
            List<ObjectNode> references = generationContext.getReferences(definitionKey);
            if (considerOnlyDirectReferences.get() && references.isEmpty()) {
                return false;
            }
            List<ObjectNode> nullableReferences = generationContext.getNullableReferences(definitionKey);
            return createDefinitionsForAll || (references.size() + nullableReferences.size()) > 1;
        };
        Map<DefinitionKey, String> baseReferenceKeys = this.getReferenceKeys(mainSchemaKey, shouldProduceDefinition, generationContext);
        considerOnlyDirectReferences.set(true);
        final boolean createDefinitionForMainSchema = this.config.shouldCreateDefinitionForMainSchema();
        for (Map.Entry<DefinitionKey, String> entry : baseReferenceKeys.entrySet()) {
            String definitionName = entry.getValue();
            DefinitionKey definitionKey = entry.getKey();
            List<ObjectNode> references = generationContext.getReferences(definitionKey);
            List<ObjectNode> nullableReferences = generationContext.getNullableReferences(definitionKey);
            final String referenceKey;
            boolean referenceInline = !shouldProduceDefinition.test(definitionKey);
            if (referenceInline) {
                // it is a simple type, just in-line the sub-schema everywhere
                ObjectNode definition = generationContext.getDefinition(definitionKey);
                references.forEach(node -> AttributeCollector.mergeMissingAttributes(node, definition));
                referenceKey = null;
            } else {
                // the same sub-schema is referenced in multiple places
                if (createDefinitionForMainSchema || !definitionKey.equals(mainSchemaKey)) {
                    // add it to the definitions (unless it is the main schema that is not explicitly moved there via an Option)
                    definitionsNode.set(definitionName, generationContext.getDefinition(definitionKey));
                    referenceKey = this.config.getKeyword(SchemaKeyword.TAG_REF_MAIN) + '/' + designatedDefinitionPath + '/' + definitionName;
                } else {
                    referenceKey = this.config.getKeyword(SchemaKeyword.TAG_REF_MAIN);
                }
                references.forEach(node -> node.put(this.config.getKeyword(SchemaKeyword.TAG_REF), referenceKey));
            }
            if (!nullableReferences.isEmpty()) {
                ObjectNode definition;
                if (referenceInline) {
                    definition = generationContext.getDefinition(definitionKey);
                } else {
                    definition = this.config.createObjectNode().put(this.config.getKeyword(SchemaKeyword.TAG_REF), referenceKey);
                }
                generationContext.makeNullable(definition);
                if (!inlineAllSchemas && (createDefinitionsForAll || nullableReferences.size() > 1)) {
                    String nullableDefinitionName = this.definitionNamingStrategy
                            .adjustNullableName(definitionKey, definitionName, generationContext);
                    definitionsNode.set(nullableDefinitionName, definition);
                    nullableReferences.forEach(node -> node.put(this.config.getKeyword(SchemaKeyword.TAG_REF),
                            this.config.getKeyword(SchemaKeyword.TAG_REF_MAIN) + '/' + designatedDefinitionPath + '/' + nullableDefinitionName));
                } else {
                    nullableReferences.forEach(node -> AttributeCollector.mergeMissingAttributes(node, definition));
                }
            }
        }
        definitionsNode.forEach(node -> this.schemaNodes.add((ObjectNode) node));
        return definitionsNode;
    }

    /**
     * Derive the applicable keys for the collected entries for the {@link SchemaKeyword#TAG_DEFINITIONS} in the given context.
     *
     * @param mainSchemaKey special definition key for the main schema
     * @param shouldProduceDefinition filter to indicate whether a given key should be considered when determining definition names
     * @param generationContext generation context in which all traversed types and their definitions have been collected
     * @return encountered types with their corresponding reference keys
     */
    private Map<DefinitionKey, String> getReferenceKeys(DefinitionKey mainSchemaKey, Predicate<DefinitionKey> shouldProduceDefinition,
            SchemaGenerationContextImpl generationContext) {
        boolean createDefinitionForMainSchema = this.config.shouldCreateDefinitionForMainSchema();
        Function<DefinitionKey, String> definitionNamesForKey = key -> this.definitionNamingStrategy.getDefinitionNameForKey(key, generationContext);
        Map<String, List<DefinitionKey>> aliases = generationContext.getDefinedTypes().stream()
                .collect(Collectors.groupingBy(definitionNamesForKey, TreeMap::new, Collectors.toList()));
        Map<DefinitionKey, String> referenceKeys = new LinkedHashMap<>();
        for (Map.Entry<String, List<DefinitionKey>> group : aliases.entrySet()) {
            group.getValue().forEach(key -> referenceKeys.put(key, ""));
            List<DefinitionKey> definitionKeys = group.getValue().stream()
                    .filter(shouldProduceDefinition)
                    .collect(Collectors.toList());
            if (definitionKeys.size() == 1
                    || (definitionKeys.size() == 2 && !createDefinitionForMainSchema && definitionKeys.contains(mainSchemaKey))) {
                definitionKeys.forEach(key -> referenceKeys.put(key, group.getKey()));
            } else {
                Map<DefinitionKey, String> referenceKeyGroup = definitionKeys.stream()
                        .collect(Collectors.toMap(key -> key, _key -> group.getKey(), (val1, _val2) -> val1, LinkedHashMap::new));
                this.definitionNamingStrategy.adjustDuplicateNames(referenceKeyGroup, generationContext);
                if (definitionKeys.size() != referenceKeyGroup.size()) {
                    throw new IllegalStateException(SchemaDefinitionNamingStrategy.class.getSimpleName()
                            + " of type " + this.definitionNamingStrategy.getClass().getSimpleName()
                            + " altered list of subschemas with duplicate names.");
                }
                referenceKeys.putAll(referenceKeyGroup);
            }
        }
        String remainingDuplicateKeys = referenceKeys.values().stream()
                .filter(value -> !value.isEmpty())
                .collect(Collectors.groupingBy(key -> key, Collectors.counting()))
                .entrySet().stream()
                .filter(entry -> entry.getValue() > 1)
                .map(Map.Entry::getKey)
                .collect(Collectors.joining(", "));
        if (!remainingDuplicateKeys.isEmpty()) {
            throw new IllegalStateException(SchemaDefinitionNamingStrategy.class.getSimpleName()
                    + " of type " + this.definitionNamingStrategy.getClass().getSimpleName()
                    + " produced duplicate keys: " + remainingDuplicateKeys);
        }
        return referenceKeys;
    }
}