/*
 * Jooby https://jooby.io
 * Apache License Version 2.0 https://jooby.io/LICENSE.txt
 * Copyright 2014 Edgar Espina
 */
package io.jooby.internal.netty;

import static io.jooby.internal.netty.NettyString.*;
import static io.jooby.internal.netty.SlowPathChecks.*;
import static io.netty.handler.codec.http.HttpUtil.isTransferEncodingChunked;

import java.nio.charset.StandardCharsets;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.jooby.*;
import io.jooby.netty.NettyServer;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.handler.codec.http.multipart.*;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.ReferenceCountUtil;

public class NettyHandler extends ChannelInboundHandlerAdapter {
  private final Logger log = LoggerFactory.getLogger(NettyServer.class);
  private final NettyDateService serverDate;
  private Router router;
  private final Context.Selector contextSelector;
  private final int bufferSize;
  private final boolean defaultHeaders;
  private final long maxRequestSize;
  private final int maxFormFields;
  private long chunkSize;
  private final boolean http2;
  private NettyContext context;
  private boolean read;
  private boolean flush;
  ChannelHandlerContext channelContext;

  public NettyHandler(
      NettyDateService serverDate,
      Context.Selector contextSelector,
      long maxRequestSize,
      int maxFormFields,
      int bufferSize,
      boolean defaultHeaders,
      boolean http2) {
    this.serverDate = serverDate;
    this.contextSelector = contextSelector;
    this.maxRequestSize = maxRequestSize;
    this.maxFormFields = maxFormFields;
    this.bufferSize = bufferSize;
    this.defaultHeaders = defaultHeaders;
    this.http2 = http2;
  }

  @Override
  public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
    this.channelContext = ctx;
    super.handlerAdded(ctx);
  }

  @Override
  public void channelRead(ChannelHandlerContext ctx, Object msg) {
    if (isHttpRequest(msg)) {
      this.read = true;
      this.chunkSize = 0;
      var req = (HttpRequest) msg;
      var path = pathOnly(req.uri());
      var app = contextSelector.select(path);
      this.router = app.getRouter();

      context = new NettyContext(this, ctx, req, app, path, bufferSize, http2);

      if (defaultHeaders) {
        context.setHeaders.set(DATE, serverDate.date());
        context.setHeaders.set(SERVER, server);
      }
      context.setHeaders.set(CONTENT_TYPE, TEXT_PLAIN);

      if (req.method() == HttpMethod.GET) {
        router.match(context).execute(context);
      } else {
        // possibly body:
        long contentLength = contentLength(req);
        if (contentLength > 0 || isTransferEncodingChunked(req)) {
          if (req.getClass() == DefaultFullHttpRequest.class) {
            // HTTP2 aggregates all into a full http request.
            if (((DefaultFullHttpRequest) req).content().readableBytes() > maxRequestSize) {
              release(req);
              router.match(context).execute(context, Route.REQUEST_ENTITY_TOO_LARGE);
              return;
            }
            // full body is here move
            router.match(context).execute(context);
          } else {
            context.httpDataFactory = new DefaultHttpDataFactory(bufferSize);
            context.httpDataFactory.setBaseDir(app.getTmpdir().toString());
            context.setDecoder(newDecoder(req, context.httpDataFactory, maxFormFields));
          }
        } else {
          // no body, move on
          router.match(context).execute(context);
        }
      }
    } else if (isHttpContent(msg)) {
      this.read = true;
      var chunk = (HttpContent) msg;
      try {
        // when decoder == null, chunk is always a LastHttpContent.EMPTY, ignore it
        if (context.decoder != null) {
          chunkSize += chunk.content().readableBytes();
          if (chunkSize > maxRequestSize) {
            resetDecoderState(context, true);
            router.match(context).execute(context, Route.REQUEST_ENTITY_TOO_LARGE);
            return;
          }
          if (offer(context, chunk) && isLastHttpContent(msg)) {
            Router.Match route = router.match(context);
            resetDecoderState(context, !route.matches());
            route.execute(context);
          }
        }
      } finally {
        release(chunk);
      }
    } else if (isWebSocketFrame(msg)) {
      if (context.webSocket != null) {
        context.webSocket.handleFrame((WebSocketFrame) msg);
      } else {
        release(msg);
      }
    }
  }

  public void writeMessage(Object msg, ChannelPromise promise) {
    if (this.channelContext.executor().inEventLoop()) {
      if (this.read) {
        this.flush = true;
        this.channelContext.write(msg, promise);
      } else {
        this.channelContext.writeAndFlush(msg, promise);
      }
    } else {
      this.channelContext.executor().execute(() -> writeMessage(msg, promise));
    }
  }

  public void writeChunks(Object header, Object body, Object last, ChannelPromise promise) {
    if (this.channelContext.executor().inEventLoop()) {
      // Headers
      var voidPromise = channelContext.voidPromise();
      channelContext.write(header, voidPromise);
      // Body
      channelContext.write(body, voidPromise);

      // Finish
      if (this.read) {
        this.flush = true;
        channelContext.write(last, promise); // Defer flush
      } else {
        channelContext.writeAndFlush(last, promise); // Immediate flush
      }
    } else {
      this.channelContext.executor().execute(() -> writeChunks(header, body, last, promise));
    }
  }

  public void write(SneakyThrows.Consumer<ChannelHandlerContext> action) {
    if (this.channelContext.executor().inEventLoop()) {
      action.accept(this.channelContext);
    } else {
      this.channelContext.executor().execute(() -> write(action));
    }
  }

  public void writeChunks(Object header, Object body, ChannelPromise promise) {
    if (this.channelContext.executor().inEventLoop()) {
      // Headers
      channelContext.write(header, channelContext.voidPromise());

      // Body + Last
      if (this.read) {
        this.flush = true;
        channelContext.write(body, promise); // Defer flush
      } else {
        channelContext.writeAndFlush(body, promise); // Immediate flush
      }
    } else {
      this.channelContext.executor().execute(() -> writeChunks(header, body, promise));
    }
  }

  private void release(Object ref) {
    if (ReferenceCountUtil.refCnt(ref) > 0) {
      ReferenceCountUtil.release(ref);
    }
  }

  @Override
  public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
    if (this.read) {
      this.read = false;
      if (this.flush) {
        this.flush = false;
        ctx.flush();
      }
    }
    super.channelReadComplete(ctx);
  }

  @Override
  public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
    if (evt instanceof IdleStateEvent) {
      NettyWebSocket ws = ctx.channel().attr(NettyWebSocket.WS).getAndSet(null);
      if (ws != null) {
        ws.close(WebSocketCloseStatus.GOING_AWAY);
      }
    }
  }

  @Override
  public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    try {
      if (Server.connectionLost(cause)) {
        if (log.isDebugEnabled()) {
          if (context == null) {
            log.debug("execution resulted in connection lost", cause);
          } else {
            log.debug("{} {}", context.getMethod(), context.getRequestPath(), cause);
          }
        }
      } else {
        if (context == null) {
          log.error("execution resulted in exception", cause);
        } else {
          if (context.getRouter().isStopped()) {
            log.debug("execution resulted in exception while application was shutting down", cause);
          } else {
            context.sendError(cause);
          }
        }
      }
    } finally {
      try {
        if (context != null) {
          resetDecoderState(context, true); // Force decoder cleanup on failure
        }
      } finally {
        ctx.close();
      }
    }
  }

  private boolean offer(NettyContext context, HttpContent chunk) {
    try {
      context.decoder.offer(chunk);
      return true;
    } catch (Exception x) {
      if (x instanceof HttpPostRequestDecoder.TooManyFormFieldsException) {
        context.setAttribute("__too_many_fields", x);
      }
      router.match(context).execute(context, Route.FORM_DECODER_HANDLER);
      return false;
    }
  }

  private void resetDecoderState(NettyContext context, boolean destroy) {
    chunkSize = 0;
    if (destroy && context.decoder != null) {
      var decoder = context.decoder;
      var httpDataFactory = context.httpDataFactory;
      context.decoder = null;
      context.httpDataFactory = null;
      httpDataFactory.cleanAllHttpData();
      decoder.destroy();
    }
  }

  private static InterfaceHttpPostRequestDecoder newDecoder(
      HttpRequest request, HttpDataFactory factory, int maxFormFields) {
    String contentType = request.headers().get(HttpHeaderNames.CONTENT_TYPE);
    if (contentType != null) {
      String lowerContentType = contentType.toLowerCase();
      if (lowerContentType.startsWith(MediaType.MULTIPART_FORMDATA)) {
        return new HttpPostMultipartRequestDecoder(
            factory, request, StandardCharsets.UTF_8, maxFormFields, -1);
      } else if (lowerContentType.startsWith(MediaType.FORM_URLENCODED)) {
        return new HttpPostStandardRequestDecoder(
            factory, request, StandardCharsets.UTF_8, maxFormFields, -1);
      }
    }
    return new HttpRawPostRequestDecoder(factory, request);
  }

  static String pathOnly(String uri) {
    int len = uri.indexOf('?');
    return len > 0 ? uri.substring(0, len) : uri;
  }

  private static long contentLength(HttpRequest req) {
    String value = req.headers().get(HttpHeaderNames.CONTENT_LENGTH);
    if (value == null) {
      return -1;
    }
    try {
      return Long.parseLong(value);
    } catch (NumberFormatException x) {
      return -1;
    }
  }
}
