/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.jooby.internal.jetty;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;

import javax.inject.Inject;
import javax.inject.Provider;
import javax.net.ssl.SSLContext;

import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.SslConnectionFactory;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.DecoratedObjectFactory;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.eclipse.jetty.websocket.api.WebSocketBehavior;
import org.eclipse.jetty.websocket.api.WebSocketPolicy;
import org.eclipse.jetty.websocket.server.WebSocketServerFactory;
import org.jooby.spi.HttpHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Throwables;
import com.google.common.primitives.Primitives;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigException;

import javaslang.control.Try;

public class JettyServer implements org.jooby.spi.Server {

  private static final String H2 = "h2";
  private static final String H2_17 = "h2-17";
  private static final String HTTP_1_1 = "http/1.1";

  private static final String JETTY_HTTP = "jetty.http";
  private static final String CONNECTOR = "connector";

  /** The logging system. */
  private final Logger log = LoggerFactory.getLogger(org.jooby.spi.Server.class);

  private Server server;

  @Inject
  public JettyServer(final HttpHandler handler, final Config conf,
      final Provider<SSLContext> sslCtx) {
    this.server = server(handler, conf, sslCtx);
  }

  private Server server(final HttpHandler handler, final Config conf,
      final Provider<SSLContext> sslCtx) {
    System.setProperty("org.eclipse.jetty.util.UrlEncoded.charset",
        conf.getString("jetty.url.charset"));

    System.setProperty("org.eclipse.jetty.server.Request.maxFormContentSize",
        conf.getBytes("server.http.MaxRequestSize").toString());

    QueuedThreadPool pool = conf(new QueuedThreadPool(), conf.getConfig("jetty.threads"),
        "jetty.threads");

    Server server = new Server(pool);
    server.setStopAtShutdown(false);

    // HTTP connector
    boolean http2 = conf.getBoolean("server.http2.enabled");

    ServerConnector http = http(server, conf.getConfig(JETTY_HTTP), JETTY_HTTP, http2);
    http.setPort(conf.getInt("application.port"));
    http.setHost(conf.getString("application.host"));

    if (conf.hasPath("application.securePort")) {

      ServerConnector https = https(server, conf.getConfig(JETTY_HTTP), JETTY_HTTP,
          sslCtx.get(), http2);
      https.setPort(conf.getInt("application.securePort"));

      server.addConnector(https);
    }

    server.addConnector(http);

    ContextHandler sch = new ContextHandler();
    sch.setAttribute(DecoratedObjectFactory.ATTR, new DecoratedObjectFactory());

    WebSocketPolicy wsConfig = conf(new WebSocketPolicy(WebSocketBehavior.SERVER),
        conf.getConfig("jetty.ws"), "jetty.ws");
    WebSocketServerFactory webSocketServerFactory = new WebSocketServerFactory(
        sch.getServletContext(), wsConfig);
    webSocketServerFactory.setCreator((req, rsp) -> {
      JettyWebSocket ws = new JettyWebSocket();
      req.getHttpServletRequest().setAttribute(JettyWebSocket.class.getName(), ws);
      return ws;
    });

    // always '/' context path is internally handle by jooby
    sch.setContextPath("/");
    sch.setHandler(new JettyHandler(handler, webSocketServerFactory, conf
        .getString("application.tmpdir"), conf.getBytes("jetty.FileSizeThreshold").intValue()));

    server.setHandler(sch);

    return server;
  }

  private ServerConnector http(final Server server, final Config conf, final String path,
      final boolean http2) {
    HttpConfiguration httpConfig = conf(new HttpConfiguration(), conf.withoutPath(CONNECTOR),
        path);

    ServerConnector connector;
    if (http2) {
      connector = new ServerConnector(server, new HttpConnectionFactory(httpConfig),
          new HTTP2CServerConnectionFactory(httpConfig));
    } else {
      connector = new ServerConnector(server, new HttpConnectionFactory(httpConfig));
    }

    return conf(connector, conf.getConfig(CONNECTOR), path + "." + CONNECTOR);
  }

  private ServerConnector https(final Server server, final Config conf, final String path,
      final SSLContext sslContext, final boolean http2) {

    HttpConfiguration httpConf = conf(new HttpConfiguration(), conf.withoutPath(CONNECTOR),
        path);

    SslContextFactory sslContextFactory = new SslContextFactory();
    sslContextFactory.setSslContext(sslContext);
    sslContextFactory.setIncludeProtocols("TLSv1.2");
    sslContextFactory.setIncludeCipherSuites("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");

    HttpConfiguration httpsConf = new HttpConfiguration(httpConf);
    httpsConf.addCustomizer(new SecureRequestCustomizer());

    HttpConnectionFactory https11 = new HttpConnectionFactory(httpsConf);

    if (http2) {
      ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17, HTTP_1_1);
      alpn.setDefaultProtocol(HTTP_1_1);

      HTTP2ServerConnectionFactory https2 = new HTTP2ServerConnectionFactory(httpsConf);

      ServerConnector connector = new ServerConnector(server,
          new SslConnectionFactory(sslContextFactory, "alpn"), alpn, https2, https11);

      return conf(connector, conf.getConfig(CONNECTOR), path + ".connector");
    } else {

      ServerConnector connector = new ServerConnector(server,
          new SslConnectionFactory(sslContextFactory, HTTP_1_1), https11);

      return conf(connector, conf.getConfig(CONNECTOR), path + ".connector");
    }
  }

  @Override
  public void start() throws Exception {
    server.start();
  }

  @Override
  public void join() throws InterruptedException {
    server.join();
  }

  @Override
  public void stop() throws Exception {
    server.stop();
  }

  @Override
  public Optional<Executor> executor() {
    return Optional.ofNullable(server.getThreadPool());
  }

  private void tryOption(final Object source, final Config config, final Method option) {
    Try.run(() -> {
      String optionName = option.getName().replace("set", "");
      Object optionValue = config.getAnyRef(optionName);
      Class<?> optionType = Primitives.wrap(option.getParameterTypes()[0]);
      if (Number.class.isAssignableFrom(optionType) && optionValue instanceof String) {
        // either a byte or time unit
        try {
          optionValue = config.getBytes(optionName);
        } catch (ConfigException.BadValue ex) {
          optionValue = config.getDuration(optionName, TimeUnit.MILLISECONDS);
        }
        if (optionType == Integer.class) {
          // to int
          optionValue = ((Number) optionValue).intValue();
        }
      }
      log.debug("{}.{}({})", source.getClass().getSimpleName(), option.getName(), optionValue);
      option.invoke(source, optionValue);
    }).onFailure(x -> {
      Throwable cause = x;
      if (x instanceof InvocationTargetException) {
        cause = ((InvocationTargetException) x).getTargetException();
      }
      Throwables.propagate(cause);
    });
  }

  private <T> T conf(final T source, final Config config, final String path) {
    Map<String, Method> methods = Arrays.stream(source.getClass().getMethods())
        .filter(m -> m.getName().startsWith("set") && m.getParameterCount() == 1)
        .collect(Collectors.toMap(Method::getName, Function.<Method> identity()));

    config.entrySet().forEach(entry -> {
      String key = "set" + entry.getKey();
      Method method = methods.get(key);
      if (method != null) {
        tryOption(source, config, method);
      } else {
        log.error("Unknown option: {}.{} for: {}", path, key, source.getClass().getName());
      }
    });

    return source;
  }

}
