package webster.netty;

import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.handler.stream.ChunkedStream;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import webster.requestresponse.*;
import webster.util.Futures;

import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.function.Function;
import java.util.stream.Collectors;

import static io.netty.handler.codec.http.HttpHeaders.Names.*;
import static io.netty.handler.codec.http.HttpHeaders.is100ContinueExpected;
import static io.netty.handler.codec.http.HttpHeaders.isKeepAlive;
import static io.netty.handler.codec.http.HttpResponseStatus.CONTINUE;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;

public class HttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    private static final Logger logger = LoggerFactory.getLogger(HttpHandler.class);

    private final Function<Request, CompletableFuture<Response>> requestHandler;
    private final ExecutorService executor;
    private final long timeoutMillis;

    public HttpHandler(Function<Request, CompletableFuture<Response>> requestHandler,
                       ExecutorService executor,
                       long timeoutMillis) {
        super(false);
        this.requestHandler = requestHandler;
        this.executor = executor;
        this.timeoutMillis = timeoutMillis;
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    @Override
    public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {

        if (is100ContinueExpected(req)) {
            ctx.write(new DefaultFullHttpResponse(HTTP_1_1, CONTINUE));
        }
        boolean keepAlive = isKeepAlive(req);
        Request request = createRequest(req);

        // requestHandler is a function that takes a request and creates a future. The creation of the future
        // might block. That's why requestHandler#apply is run on another thread.
        CompletableFuture<Response> timeout = timeoutResponseFuture();
        CompletableFuture
                .supplyAsync(() -> requestHandler.apply(request), executor) // creation of the future
                .thenCompose(f -> f.exceptionally(Response::new)) // handle exceptions during future creation
                .acceptEither(timeout, r -> {
                    ReferenceCountUtil.release(req);
                    handleResponse(r, ctx, keepAlive); // handle response of either timeout or requestHandler
                })
                .whenComplete((v, e) -> {
                    if(e != null)
                        exceptionCaught(ctx, e);
                });
    }

    private CompletableFuture<Response> timeoutResponseFuture() {
        return Futures.afterTimeout(new Response(500, Responses.bodyFrom("request processing timed out")), timeoutMillis);
    }

    private void handleResponse(Response response, ChannelHandlerContext context, boolean keepAlive) {
        response.body().process(new ResponseBodyProcessor<Void>() {
            @Override
            public Void process(StringResponseBody body) {
                handleFullResponse(
                        createFullResponse(response.status(), response.headers(), body.content()),
                        context, keepAlive);
                return null;
            }

            @Override
            public Void process(InputStreamResponseBody body) {
                handleStreamResponse(response.status(), response.headers(), body.content(), context, keepAlive);
                return null;
            }

            @Override
            public Void process(EmptyResponseBody body) {
                handleFullResponse(
                        createFullResponse(response.status(), response.headers(), ""),
                        context, keepAlive);
                return null;
            }
        });
    }

    private void handleFullResponse(FullHttpResponse response, ChannelHandlerContext context, boolean keepAlive) {
        if (!keepAlive) {
            context.write(response).addListener(ChannelFutureListener.CLOSE);
        } else {
            response.headers().set(CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
            context.write(response);
        }
        context.flush();
    }

    private void handleStreamResponse(int status, Map<String, String> headers, InputStream body,
                                      ChannelHandlerContext context, boolean keepAlive) {
        // TODO no chunked encoding for http 1.0 clients
        HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.valueOf(status));
        response.headers().set(TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED);
        headers.entrySet().stream().forEach(header ->
                response.headers().set(header.getKey(), header.getValue()));
        context.write(response);

        context.write(new ChunkedStream(body));
        ChannelFuture lastContentFuture = context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
        if (!keepAlive) {
            lastContentFuture.addListener(ChannelFutureListener.CLOSE);
        }
    }

    private FullHttpResponse createFullResponse(int status, Map<String, String> headers, String body) {
        FullHttpResponse response = new DefaultFullHttpResponse(
                HTTP_1_1,
                HttpResponseStatus.valueOf(status),
                body.isEmpty() ? Unpooled.buffer(0) : Unpooled.wrappedBuffer(body.getBytes(Charset.forName("UTF-8")))); // TODO charset
        response.headers().set(CONTENT_LENGTH, response.content().readableBytes());
        headers.entrySet().stream().forEach(header ->
                response.headers().set(header.getKey(), header.getValue()));
        return response;
    }

    private Request createRequest(FullHttpRequest req) {
        InputStream body = new ByteBufInputStream(req.content());
        Map<String, String> headers = new HashMap<>(req.headers().names().size());
        for (String header : req.headers().names()) {
            String headerValue = req.headers().getAll(header).stream().collect(Collectors.joining(","));
            headers.put(header, headerValue);
        }
        QueryStringDecoder decoder = new QueryStringDecoder(req.getUri());// TODO charset
        return new Request(req.getMethod().name(), decoder.path(), Collections.unmodifiableMap(headers), body,
                decoder.parameters(), null, null);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        logger.error(cause.getMessage(), cause);
        FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.valueOf(500));
        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
    }
}