/*
 * Copyright (C) 2017-2020 HERE Europe B.V.
 *
 * 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.
 *
 * SPDX-License-Identifier: Apache-2.0
 * License-Filename: LICENSE
 */

package com.here.xyz.hub;

import static com.here.xyz.hub.rest.Api.HeaderValues.APPLICATION_JSON;
import static com.here.xyz.hub.rest.Api.HeaderValues.STREAM_ID;
import static com.here.xyz.hub.rest.Api.HeaderValues.STREAM_INFO;
import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
import static io.vertx.core.http.HttpHeaders.AUTHORIZATION;
import static io.vertx.core.http.HttpHeaders.CACHE_CONTROL;
import static io.vertx.core.http.HttpHeaders.CONTENT_LENGTH;
import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
import static io.vertx.core.http.HttpHeaders.ETAG;
import static io.vertx.core.http.HttpHeaders.IF_MODIFIED_SINCE;
import static io.vertx.core.http.HttpHeaders.IF_NONE_MATCH;
import static io.vertx.core.http.HttpHeaders.USER_AGENT;
import static io.vertx.core.http.HttpMethod.DELETE;
import static io.vertx.core.http.HttpMethod.GET;
import static io.vertx.core.http.HttpMethod.OPTIONS;
import static io.vertx.core.http.HttpMethod.PATCH;
import static io.vertx.core.http.HttpMethod.POST;
import static io.vertx.core.http.HttpMethod.PUT;

import com.here.xyz.hub.auth.Authorization.AuthorizationType;
import com.here.xyz.hub.auth.JWTURIHandler;
import com.here.xyz.hub.auth.JwtDummyHandler;
import com.here.xyz.hub.auth.XyzAuthProvider;
import com.here.xyz.hub.rest.AdminApi;
import com.here.xyz.hub.rest.Api;
import com.here.xyz.hub.rest.FeatureApi;
import com.here.xyz.hub.rest.FeatureQueryApi;
import com.here.xyz.hub.rest.HttpException;
import com.here.xyz.hub.rest.SpaceApi;
import com.here.xyz.hub.rest.health.HealthApi;
import com.here.xyz.hub.util.OpenApiTransformer;
import com.here.xyz.hub.util.logging.LogUtil;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.Json;
import io.vertx.ext.auth.PubSecKeyOptions;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.api.contract.RouterFactoryOptions;
import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory;
import io.vertx.ext.web.handler.AuthHandler;
import io.vertx.ext.web.handler.ChainAuthHandler;
import io.vertx.ext.web.handler.CorsHandler;
import io.vertx.ext.web.handler.JWTAuthHandler;
import io.vertx.ext.web.handler.StaticHandler;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Marker;

public class XYZHubRESTVerticle extends AbstractVerticle {

  private static final Logger logger = LogManager.getLogger();

  private static final HttpServerOptions SERVER_OPTIONS = new HttpServerOptions()
      .setCompressionSupported(true)
      .setDecompressionSupported(true)
      .setHandle100ContinueAutomatically(true)
      .setTcpQuickAck(true)
      .setTcpFastOpen(true)
      .setMaxInitialLineLength(16 * 1024)
      .setIdleTimeout(300);

  private static String FULL_API;
  private static String STABLE_API;
  private static String EXPERIMENTAL_API;
  private static String CONTRACT_API;
  private static String CONTRACT_LOCATION;

  static {
    try {
      final OpenApiTransformer openApi = OpenApiTransformer.generateAll();
      FULL_API = openApi.fullApi;
      STABLE_API = openApi.stableApi;
      EXPERIMENTAL_API = openApi.experimentalApi;
      CONTRACT_API = openApi.contractApi;
      CONTRACT_LOCATION = openApi.contractLocation;
    } catch (Exception e) {
      logger.error("Unable to generate OpenApi specs.", e);
    }
  }

  /**
   * The methods the client is allowed to use.
   */
  private final List<HttpMethod> allowMethods = Arrays.asList(OPTIONS, GET, POST, PUT, DELETE, PATCH);

  /**
   * The headers, which can be exposed as part of the response.
   */
  private final List<CharSequence> exposeHeaders = Arrays.asList(STREAM_ID, STREAM_INFO, ETAG);

  /**
   * The headers the client is allowed to send.
   */
  private final List<CharSequence> allowHeaders = Arrays.asList(
      AUTHORIZATION, CONTENT_TYPE, USER_AGENT, IF_MODIFIED_SINCE, IF_NONE_MATCH, CACHE_CONTROL, STREAM_ID
  );

  private FeatureApi featureApi;
  private FeatureQueryApi featureQueryApi;
  private SpaceApi spaceApi;
  private HealthApi healthApi;
  private AdminApi adminApi;

  /**
   * The final response handler.
   */
  private static void onResponseSent(RoutingContext context) {
    final Marker marker = Api.Context.getMarker(context);
    logger.info(marker, "{}", LogUtil.responseToLogEntry(context));
    LogUtil.addResponseInfo(context).end();
    LogUtil.writeAccessLog(context);
  }

  private static void failureHandler(RoutingContext context) {
    if (context.failure() != null) {
      sendErrorResponse(context, context.failure());
    } else {
      String message = context.statusCode() == 401 ? "Missing auth credentials." : "A failure occurred during the execution.";
      HttpResponseStatus status = context.statusCode() >= 400 ? HttpResponseStatus.valueOf(context.statusCode()) : INTERNAL_SERVER_ERROR;
      sendErrorResponse(context, new HttpException(status, message));
    }
  }

  /**
   * The default NOT FOUND handler.
   */
  private static void notFoundHandler(final RoutingContext context) {
    sendErrorResponse(context, new HttpException(NOT_FOUND, "The requested resource does not exist."));
  }

  /**
   * Creates and sends an error response to the client.
   */
  public static void sendErrorResponse(final RoutingContext context, final Throwable exception) {
    ErrorMessage error;

    try {
      final Marker marker = Api.Context.getMarker(context);

      error = new ErrorMessage(context, exception);
      if (error.statusCode == 500) {
        error.message = null;
        logger.error(marker, "Sending error response: {} {} {}", error.statusCode, error.reasonPhrase, exception);
      }
      else {
        logger.warn(marker, "Sending error response: {} {} {}", error.statusCode, error.reasonPhrase, exception);
      }
    } catch (Exception e) {
      logger.error("Error {} while preparing error response {}", e, exception);
      error = new ErrorMessage();
    }

    context.response()
        .putHeader(CONTENT_TYPE, APPLICATION_JSON)
        .setStatusCode(error.statusCode)
        .setStatusMessage(error.reasonPhrase)
        .end(Json.encode(error));
  }

  /**
   * The initial request handler.
   */
  private void onRequestReceived(final RoutingContext context) {
    if (context.request().getHeader(STREAM_ID) == null) {
      context.request().headers().add(STREAM_ID, RandomStringUtils.randomAlphanumeric(10));
    }

    //Log the request information.
    LogUtil.addRequestInfo(context);

    context.response().putHeader(STREAM_ID, context.request().getHeader(STREAM_ID));
    context.response().endHandler(ar -> XYZHubRESTVerticle.onResponseSent(context));
    context.next();
  }

  @Override
  public void start(Future<Void> fut) {
    OpenAPI3RouterFactory.create(vertx, CONTRACT_LOCATION, ar -> {
      if (ar.succeeded()) {
        //Add the handlers
        final OpenAPI3RouterFactory routerFactory = ar.result();
        routerFactory.setOptions(new RouterFactoryOptions());
        featureApi = new FeatureApi(routerFactory);
        featureQueryApi = new FeatureQueryApi(routerFactory);
        spaceApi = new SpaceApi(routerFactory);

        final AuthHandler jwtHandler = createJWTHandler();
        routerFactory.addSecurityHandler("authToken", jwtHandler);

        final Router router = routerFactory.getRouter();
        //Add additional handler to the router
        router.route().failureHandler(XYZHubRESTVerticle::failureHandler);
        router.route().order(0)
            .handler(this::onRequestReceived)
            .handler(createCorsHandler());

        this.healthApi = new HealthApi(vertx, router);
        this.adminApi = new AdminApi(vertx, router, jwtHandler);

        //OpenAPI resources
        router.route("/hub/static/openapi/*").handler(createCorsHandler()).handler((routingContext -> {
          final HttpServerResponse res = routingContext.response();
          final String path = routingContext.request().path();
          if (path.endsWith("full.yaml")) {
            res.headers().add(CONTENT_LENGTH, String.valueOf(FULL_API.getBytes().length));
            res.write(FULL_API);
          } else if (path.endsWith("stable.yaml")) {
            res.headers().add(CONTENT_LENGTH, String.valueOf(STABLE_API.getBytes().length));
            res.write(STABLE_API);
          } else if (path.endsWith("experimental.yaml")) {
            res.headers().add(CONTENT_LENGTH, String.valueOf(EXPERIMENTAL_API.getBytes().length));
            res.write(EXPERIMENTAL_API);
          } else if (path.endsWith("contract.yaml")) {
            res.headers().add(CONTENT_LENGTH, String.valueOf(CONTRACT_API.getBytes().length));
            res.write(CONTRACT_API);
          } else {
            res.setStatusCode(HttpResponseStatus.NOT_FOUND.code());
          }

          res.end();
        }));

        //Static resources
        router.route("/hub/static/*").handler(StaticHandler.create().setIndexPage("index.html")).handler(createCorsHandler());
        if (Service.configuration.FS_WEB_ROOT != null) {
          logger.debug("Serving extra web-root folder in file-system with location: {}", Service.configuration.FS_WEB_ROOT);
          //noinspection ResultOfMethodCallIgnored
          new File(Service.configuration.FS_WEB_ROOT).mkdirs();
          router.route("/hub/static/*")
              .handler(StaticHandler.create(Service.configuration.FS_WEB_ROOT).setIndexPage("index.html"));
        }

        //Default NotFound handler
        router.route().last().handler(XYZHubRESTVerticle::notFoundHandler);

        vertx.createHttpServer(SERVER_OPTIONS)
            .requestHandler(router)
            .listen(
                Service.configuration.HTTP_PORT, result -> {
                  if (result.succeeded()) {
                    createMessageServer(router, fut);
                  } else {
                    logger.error("An error occurred, during the initialization of the server.", result.cause());
                    fut.fail(result.cause());
                  }
                });
      } else {
        logger.error("An error occurred, during the creation of the router from the Open API specification file.", ar.cause());
      }
    });
  }

  protected void createMessageServer(Router router, Future<Void> fut) {
    int messagePort = Service.configuration.ADMIN_MESSAGE_PORT;
    if (messagePort != Service.configuration.HTTP_PORT) {
      //Create 2nd HTTP server for admin-messaging
      vertx.createHttpServer(SERVER_OPTIONS)
          .requestHandler(router)
          .listen(messagePort, result -> {
            if (result.succeeded()) {
              logger.debug("HTTP server also listens on admin-messaging port {}.", messagePort);
            }
            else {
              logger.error("An error occurred, during the initialization of admin-messaging http port" + messagePort
                      + ". Messaging won't work correctly.",
                  result.cause());
            }
            //Complete in any case as the admin-messaging is not essential
            fut.complete();
          });
    }
    else
      fut.complete();
  }

  /**
   * Add support for cross origin requests.
   */
  private CorsHandler createCorsHandler() {
    CorsHandler cors = CorsHandler.create(".*").allowCredentials(true);
    allowMethods.forEach(cors::allowedMethod);
    allowHeaders.stream().map(String::valueOf).forEach(cors::allowedHeader);
    exposeHeaders.stream().map(String::valueOf).forEach(cors::exposedHeader);
    return cors;
  }

  /**
   * Add the security handlers.
   */
  private AuthHandler createJWTHandler() {
    JWTAuthOptions authConfig = new JWTAuthOptions().addPubSecKey(
        new PubSecKeyOptions().setAlgorithm("RS256")
            .setPublicKey(Service.configuration.JWT_PUB_KEY));

    JWTAuth authProvider = new XyzAuthProvider(vertx, authConfig);

    ChainAuthHandler authHandler = ChainAuthHandler.create()
        .append(JWTAuthHandler.create(authProvider))
        .append(JWTURIHandler.create(authProvider));

    if (Service.configuration.XYZ_HUB_AUTH == AuthorizationType.DUMMY) {
      authHandler.append(JwtDummyHandler.create(authProvider));
    }

    return authHandler;
  }

  /**
   * Represents an error object response.
   */
  private static class ErrorMessage {

    public String type = "error";
    public int statusCode = INTERNAL_SERVER_ERROR.code();
    public String reasonPhrase = INTERNAL_SERVER_ERROR.reasonPhrase();
    public String message;
    public String streamId;

    public ErrorMessage() {}

    public ErrorMessage(RoutingContext context, Throwable e) {
      Marker marker = Api.Context.getMarker(context);
      this.streamId = marker.getName();
      this.message = e.getMessage();
      if (e instanceof HttpException) {
        this.statusCode = ((HttpException) e).status.code();
        this.reasonPhrase = ((HttpException) e).status.reasonPhrase();
      }

      // The authentication providers do not pass the exception message
      if (statusCode == 401 && message == null) {
        message = "Access to this resource requires valid authentication credentials.";
      }
    }
  }
}