package io.vertx.ext.web.api.contract.openapi3.impl;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.*;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.parser.ObjectMapperFactory;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.contract.RouterFactoryException;
import io.vertx.ext.web.api.validation.SpecFeatureNotSupportedException;

import java.lang.reflect.Method;
import java.util.*;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * @author Francesco Guardiani @slinkydeveloper
 */
public class OpenApi3Utils {

  private static final JsonMetaSchema META_SCHEMA = JsonMetaSchema
    .builder(JsonMetaSchema.getV4().getUri(), JsonMetaSchema.getV4())
    .addKeyword(new NonValidationKeyword("example"))
    .build();
  private static final JsonSchemaFactory SCHEMA_FACTORY = new JsonSchemaFactory.Builder()
    .defaultMetaSchemaURI(META_SCHEMA.getUri())
    .addMetaSchema(META_SCHEMA)
    .build();
  private static final SchemaValidatorsConfig VALIDATOR_CONFIG = new SchemaValidatorsConfig();

  static {
    VALIDATOR_CONFIG.setTypeLoose(false);
    VALIDATOR_CONFIG.setHandleNullableField(true);
  }

  public static JsonSchema parseJsonSchema(JsonNode schema) {
    return SCHEMA_FACTORY.getSchema(schema, VALIDATOR_CONFIG);
  }

  public static ParseOptions getParseOptions() {
    ParseOptions options = new ParseOptions();
    options.setResolve(true);
    options.setResolveCombinators(false);
    options.setResolveFully(true);
    return options;
  }

  public static boolean isParameterArrayType(Parameter parameter) {
    return isSchemaArray(parameter.getSchema());
  }

  public static boolean isSchemaArray(Schema schema) {
    if (schema != null && schema.getType() != null)
      return schema.getType().equals("array");
    else return false;
  }

  public static boolean isParameterObjectOrAllOfType(Parameter parameter) {
    return isSchemaObjectOrAllOfType(parameter.getSchema());
  }

  public static boolean isSchemaObjectOrAllOfType(Schema schema) {
    return isSchemaObject(schema) || isAllOfSchema(schema);
  }

  public static boolean isSchemaObject(Schema schema) {
    return schema != null && ("object".equals(schema.getType()) || schema.getProperties() != null);
  }

  public static boolean isRequiredParam(Schema schema, String parameterName) {
    return schema != null && schema.getRequired() != null && schema.getRequired().contains(parameterName);
  }

  public static boolean isRequiredParam(Parameter param) {
    return Boolean.TRUE.equals(param.getRequired());
  }

  public static String resolveStyle(Parameter param) {
    if (param.getStyle() != null) return param.getStyle().toString();
    else switch (param.getIn()) {
      case "query":
        return "form";
      case "path":
        return "simple";
      case "header":
        return "simple";
      case "cookie":
        return "form";
      default:
        return null;
    }
  }

  public static boolean isOneOfSchema(Schema schema) {
    if (!(schema instanceof ComposedSchema)) return false;
    ComposedSchema composedSchema = (ComposedSchema) schema;
    return (composedSchema.getOneOf() != null && composedSchema.getOneOf().size() != 0);
  }

  public static boolean isAnyOfSchema(Schema schema) {
    if (!(schema instanceof ComposedSchema)) return false;
    ComposedSchema composedSchema = (ComposedSchema) schema;
    return (composedSchema.getAnyOf() != null && composedSchema.getAnyOf().size() != 0);
  }

  public static boolean isAllOfSchema(Schema schema) {
    if (!(schema instanceof ComposedSchema)) return false;
    ComposedSchema composedSchema = (ComposedSchema) schema;
    return (composedSchema.getAllOf() != null && composedSchema.getAllOf().size() != 0);
  }

  public static boolean resolveAllowEmptyValue(Parameter parameter) {
    if (parameter.getAllowEmptyValue() != null) {
      // As OAS says: This is valid only for query parameters and allows sending a parameter with an empty value. Default value is false. If style is used, and if behavior is n/a (cannot be serialized), the value of allowEmptyValue SHALL be ignored
      if (!"form".equals(resolveStyle(parameter)))
        return false;
      else
        return parameter.getAllowEmptyValue();
    } else return false;
  }

  // Thank you StackOverflow :) https://stackoverflow
  // .com/questions/28332924/case-insensitive-matching-of-a-string-to-a-java-enum :)
  public static <T extends Enum<?>> T searchEnum(Class<T> enumeration, String search) {
    for (T each : enumeration.getEnumConstants()) {
      if (each.name().compareToIgnoreCase(search) == 0) {
        return each;
      }
    }
    return null;
  }

  public static String resolveContentTypeRegex(String listContentTypes) {
    // Check if it's list
    if (listContentTypes.contains(",")) {
      StringBuilder stringBuilder = new StringBuilder();
      String[] contentTypes = listContentTypes.split(",");
      for (String contentType : contentTypes)
        stringBuilder.append(Pattern.quote(contentType.trim()) + "|");
      stringBuilder.deleteCharAt(stringBuilder.length() - 1);
      return stringBuilder.toString();
    } else if (listContentTypes.trim().endsWith("/*")) {
      StringBuilder stringBuilder = new StringBuilder();
      stringBuilder.append(listContentTypes.trim(), 0, listContentTypes.indexOf("/*"));
      stringBuilder.append("\\/.*");
      return stringBuilder.toString();
    } return Pattern.quote(listContentTypes);
  }

  public static List<Parameter> mergeParameters(List<Parameter> operationParameters, List<Parameter> parentParameters) {
    if (parentParameters == null && operationParameters == null) {
      return new ArrayList<>();
    } else if (operationParameters == null) {
      return new ArrayList<>(parentParameters);
    } else if (parentParameters == null) {
      return new ArrayList<>(operationParameters);
    } else {
      List<Parameter> result = new ArrayList<>(operationParameters);
      List<Parameter> actualParams = new ArrayList<>(operationParameters);
      for (Parameter parentParameter : parentParameters) {
        for (Parameter actualParam1 : actualParams) {
          Parameter parentParam = parentParameter;
          Parameter actualParam = actualParam1;
          if (!(parentParam.getIn().equalsIgnoreCase(actualParam.getIn()) && parentParam.getName().equals(actualParam
            .getName())))
            result.add(parentParam);
        }
      }
      return result;
    }
  }

  protected static class ObjectField {
    Schema schema;
    boolean required;

    public ObjectField(Schema schema, String name, Schema superSchema) {
      this.schema = schema;
      this.required = superSchema.getRequired() != null && superSchema.getRequired().contains(name);
    }

    public Schema getSchema() {
      return schema;
    }

    public boolean isRequired() {
      return required;
    }
  }

  /* This function resolve all properties inside an allOf array of schemas */
  public static Map<String, ObjectField> resolveAllOfArrays(List<Schema> allOfSchemas) {
    Map<String, ObjectField> properties = new HashMap<>();
    for (Schema schema : allOfSchemas) {
      if (schema.getType() != null && !schema.getType().equals("object"))
        throw new SpecFeatureNotSupportedException("allOf only allows inner object types");
      for (Map.Entry<String, ? extends Schema> entry : ((Map<String, Schema>) schema.getProperties()).entrySet()) {
        properties.put(entry.getKey(), new OpenApi3Utils.ObjectField(entry.getValue(), entry.getKey(), schema));
      }
    }
    return properties;
  }

  /* This function check if schema is an allOf array or an object and returns a map of properties */
  public static Map<String, ObjectField> solveObjectParameters(Schema schema) {
    if (OpenApi3Utils.isSchemaObjectOrAllOfType(schema)) {
      if (OpenApi3Utils.isAllOfSchema(schema)) {
        // allOf case
        ComposedSchema composedSchema = (ComposedSchema) schema;
        return resolveAllOfArrays(new ArrayList<>(composedSchema.getAllOf()));
      } else {
        // type object case
        Map<String, ObjectField> properties = new HashMap<>();
        if (schema.getProperties() == null) return new HashMap<>();
        for (Map.Entry<String, ? extends Schema> entry : ((Map<String, Schema>) schema.getProperties()).entrySet()) {
          properties.put(entry.getKey(), new OpenApi3Utils.ObjectField(entry.getValue(), entry.getKey(), schema));
        }
        return properties;
      }
    } else return null;
  }

  private final static Pattern COMPONENTS_REFS_MATCHER = Pattern.compile("^\\#\\/components\\/schemas\\/(.+)$");
  private final static String COMPONENTS_REFS_SUBSTITUTION = "\\#\\/definitions\\/$1";

  public static JsonNode generateSanitizedJsonSchemaNode(Schema s, OpenAPI oas) {
    ObjectNode node = ObjectMapperFactory.createJson().convertValue(s, ObjectNode.class);
    walkAndSolve(node, node, oas);
    return node;
  }

  private static void walkAndSolve(ObjectNode n, ObjectNode root, OpenAPI oas) {
    if (n.has("$ref")) {
      replaceRef(n, root, oas);
    } else if (n.has("allOf")) {
      for (JsonNode jsonNode : n.get("allOf")) {
        // We assert that parser validated allOf as array of objects
        walkAndSolve((ObjectNode) jsonNode, root, oas);
      }
    } else if (n.has("anyOf")) {
      for (JsonNode jsonNode : n.get("anyOf")) {
        walkAndSolve((ObjectNode) jsonNode, root, oas);
      }
    } else if (n.has("oneOf")) {
      for (JsonNode jsonNode : n.get("oneOf")) {
        walkAndSolve((ObjectNode) jsonNode, root, oas);
      }
    } else if (n.has("properties")) {
      ObjectNode properties = (ObjectNode) n.get("properties");
      Iterator<String> it = properties.fieldNames();
      while (it.hasNext()) {
        walkAndSolve((ObjectNode) properties.get(it.next()), root, oas);
      }
    } else if (n.has("items")) {
      walkAndSolve((ObjectNode) n.get("items"), root, oas);
    } else if (n.has("additionalProperties")) {
      JsonNode jsonNode = n.get("additionalProperties");
      if (jsonNode.getNodeType().equals(JsonNodeType.OBJECT)) {
        walkAndSolve((ObjectNode) n.get("additionalProperties"), root, oas);
      }
    }
  }

  private static void replaceRef(ObjectNode n, ObjectNode root, OpenAPI oas) {
    /**
     * If a ref is found, the structure of the schema is circular. The oas parser don't solve circular refs.
     * So I bundle the schema:
     * 1. I update the ref field with a #/definitions/schema_name uri
     * 2. If #/definitions/schema_name is empty, I solve it
     */
    String oldRef = n.get("$ref").asText();
    Matcher m = COMPONENTS_REFS_MATCHER.matcher(oldRef);
    if (m.lookingAt()) {
      String schemaName = m.group(1);
      String newRef = m.replaceAll(COMPONENTS_REFS_SUBSTITUTION);
      n.remove("$ref");
      n.put("$ref", newRef);
      if (!root.has("definitions") || !root.get("definitions").has(schemaName)) {
        Schema s = oas.getComponents().getSchemas().get(schemaName);
        ObjectNode schema = ObjectMapperFactory.createJson().convertValue(s, ObjectNode.class);
        // We need to search inside for other refs
        if (!root.has("definitions")) {
          ObjectNode definitions = root.putObject("definitions");
          definitions.set(schemaName, schema);
        } else {
          ((ObjectNode)root.get("definitions")).set(schemaName, schema);
        }
        walkAndSolve(schema, root, oas);
      }
    } else throw new RuntimeException("Wrong ref! " + oldRef);
  }

  public static List<MediaType> extractTypesFromMediaTypesMap(Map<String, MediaType> types, Predicate<String> matchingFunction) {
    return types
      .entrySet().stream()
      .filter(e -> matchingFunction.test(e.getKey()))
      .map(Map.Entry::getValue).collect(Collectors.toList());
  }

  public final static List<Class> SERVICE_PROXY_METHOD_PARAMETERS = Arrays.asList(new Class[]{OperationRequest.class, Handler.class});

  public static boolean serviceProxyMethodIsCompatibleHandler(Method method) {
    java.lang.reflect.Parameter[] parameters = method.getParameters();
    if (parameters.length < 2) return false;
    if (!parameters[parameters.length - 1].getType().equals(Handler.class)) return false;
    return parameters[parameters.length - 2].getType().equals(OperationRequest.class);
  }

  public static JsonObject sanitizeDeliveryOptionsExtension(JsonObject jsonObject) {
    JsonObject newObj = new JsonObject();
    if (jsonObject.containsKey("timeout")) newObj.put("timeout", jsonObject.getValue("timeout"));
    if (jsonObject.containsKey("headers")) newObj.put("headers", jsonObject.getValue("headers"));
    return newObj;
  }

  public static String sanitizeOperationId(String operationId) {
    StringBuilder result = new StringBuilder();
    for (int i = 0; i < operationId.length(); i++) {
      char c = operationId.charAt(i);
      if (c == '-' || c == ' ' || c == '_') {
        try {
          while (c == '-' || c == ' ' || c == '_') {
            i++;
            c = operationId.charAt(i);
          }
          result.append(Character.toUpperCase(operationId.charAt(i)));
        } catch (StringIndexOutOfBoundsException e) {}
      } else {
        result.append(c);
      }
    }
    return result.toString();
  }

  public static Object getAndMergeServiceExtension(String extensionKey, String addressKey, String methodKey, PathItem pathModel, Operation operationModel) {
    Object pathExtension = pathModel.getExtensions() != null ? pathModel.getExtensions().get(extensionKey) : null;
    Object operationExtension = operationModel.getExtensions() != null ? operationModel.getExtensions().get(extensionKey) : null;

    // Cases:
    // 1. both strings or path extension null: operation extension overrides all
    // 2. path extension map and operation extension string: path extension interpreted as delivery options and operation extension as address
    // 3. path extension string and operation extension map: path extension interpreted as address
    // 4. both maps: extension map overrides path map elements
    // 5. operation extension null: path extension overrides all

    if ((operationExtension instanceof String && pathExtension instanceof String) || pathExtension == null) return operationExtension;
    if (operationExtension instanceof String && pathExtension instanceof Map) {
      Map<String, Object> result = new HashMap<>();
      result.put(addressKey, operationExtension);
      Map<String, Object> pathExtensionMap = (Map<String, Object>) pathExtension;
      if (pathExtensionMap.containsKey(methodKey)) throw RouterFactoryException.createWrongExtension("Extension " + extensionKey + " in path declaration must not contain " + methodKey);
      pathExtensionMap.forEach(result::putIfAbsent);
      return result;
    }
    if (operationExtension instanceof Map && pathExtension instanceof String) {
      Map<String, Object> result = (Map<String, Object>) operationExtension;
      result.putIfAbsent(addressKey, pathExtension);
      return result;
    }
    if (operationExtension instanceof Map && pathExtension instanceof Map) {
      Map<String, Object> result = (Map<String, Object>) operationExtension;
      ((Map<String, Object>)pathExtension).forEach(result::putIfAbsent);
      return result;
    }
    if (operationExtension == null) return pathExtension;
    return null;
  }

public static final UnaryOperator<Boolean> safeBoolean = in -> in == null? Boolean.FALSE: in.booleanValue();

}
