package com.xjeffrose.xio.http;

import io.netty.channel.*;
import io.netty.util.concurrent.Future;
import java.net.InetSocketAddress;
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Client {
  Queue<ClientPayload> requestQueue = new ArrayDeque<>();
  private ChannelFutureListener writeListener;
  private ClientConnectionManager manager;
  private ClientState state;

  public Client(ClientState state, ClientConnectionManager manager) {
    this.state = state;
    this.manager = manager;
    writeListener =
        f -> {
          if (f.isDone() && f.isSuccess()) {
            log.debug("Write succeeded");
          } else {
            log.error("Write failed", f.cause());
            if (manager.currentChannel() != null) {
              log.debug("pipeline: {}", manager.currentChannel().pipeline());
            }
          }
        };
  }

  public InetSocketAddress remoteAddress() {
    return state.remote;
  }

  /**
   * Combines the connection and writing into one command. This method dispatches both a connect and
   * command call concurrently. If there is already an existing channel we just do the write
   *
   * @param request The Request object that we ultimately want to send outbound
   * @return A ChannelFuture that succeeds when both the connect and write succeed
   */
  public Optional<ChannelFuture> write(Request request) {
    ChannelPromise promise;
    if (manager.connectionState() == ClientConnectionState.NOT_CONNECTED) {
      // If we are not in a connected state we should buffer the requests until we find out
      // what happened to the connection try.  The connectFuture calls back on the same event loop,
      // since we are never reconnecting clients for different server channel event loops
      log.debug(
          "== No channel exists, lets connect on client: " + this + " with request: " + request);
      ChannelFuture connectFuture = manager.connect();
      promise = manager.currentChannel().newPromise();
      log.debug("== Adding req: " + request + " to queue on client: " + this);
      this.requestQueue.add(new Client.ClientPayload(request, promise));
      connectFuture.addListener(this::executeBufferedRequests);
      return Optional.of(promise);
    } else if (manager.connectionState() == ClientConnectionState.CONNECTING) {
      // we are in the middle of connecting so lets just add to the queue
      // this is a non concurrent queue because these write calls methods will be called on the
      // same event loop as the connectFuture.listener callback. We do not reconnect on previously
      // connected clients so we don't have to worry about new server channel's trying to call connect
      // on a client that was bound to previous server channel's event loop
      promise = manager.currentChannel().newPromise();
      log.debug("== Adding req: " + request + " to queue on client: " + this);
      this.requestQueue.add(new Client.ClientPayload(request, promise));
      return Optional.of(promise);
    } else if (manager.connectionState() == ClientConnectionState.CONNECTED) {
      // we are already connected so fire away
      log.debug("== already connected, just writing req: " + request + " on client: " + this);
      return Optional.of(this.rawWrite(request));
    } else {
      log.error("Connect failed on client: " + this);
      return Optional.empty();
    }
  }

  private ChannelFuture rawWrite(Request request) {
    return request.endOfMessage()
        ? manager.currentChannel().writeAndFlush(request).addListener(this.writeListener)
        : manager.currentChannel().write(request).addListener(this.writeListener);
  }

  private void executeBufferedRequests(Future<? super Void> connectionResult) {
    boolean connectionSuccess = connectionResult.isDone() && connectionResult.isSuccess();
    log.debug("== Connection success was " + connectionSuccess);
    // loop through the queue until it's empty and fire away
    // this will happen on the same event loop as the write so we don't need to worry about
    // trying to write to this queue at the same time we dequeue
    while (!requestQueue.isEmpty()) {
      Client.ClientPayload requestPayload = requestQueue.remove();
      log.debug("== Dequeue req: " + requestPayload.request + " on client: " + this);
      if (connectionSuccess) {
        this.rawWrite(requestPayload.request)
            .addListener(
                (writeResult) -> {
                  if (writeResult.isDone() && writeResult.isSuccess()) {
                    log.debug(
                        "== Req: " + requestPayload.request + " succeeded on client: " + this);
                    requestPayload.promise.setSuccess();
                  } else {
                    log.error("Req: failed on client: " + this);
                    final Throwable cause;
                    if (connectionResult.cause() != null) {
                      cause = connectionResult.cause();
                    } else {
                      cause = new RuntimeException("unknown cause");
                    }
                    requestPayload.promise.setFailure(cause);
                  }
                });
      } else {
        log.error("Req: failed on client: " + this);
        requestPayload.promise.setFailure(connectionResult.cause());
      }
    }
  }

  public void prepareForReuse(Supplier<ChannelHandler> handlerSupplier) {
    manager.setBackendHandlerSupplier(handlerSupplier);
    if (manager.currentChannel() != null) {
      manager
          .currentChannel()
          .pipeline()
          .addLast(ClientChannelInitializer.APP_HANDLER, handlerSupplier.get());
    }
  }

  public void recycle() {
    if (manager.currentChannel() != null) {
      manager.currentChannel().pipeline().remove(ClientChannelInitializer.APP_HANDLER);
      Http2ClientStreamMapper.http2ClientStreamMapper(
              manager.currentChannel().pipeline().firstContext())
          .clear();
    }
  }

  private class ClientPayload {
    public final Request request;
    public final ChannelPromise promise;

    public ClientPayload(Request request, ChannelPromise promise) {
      this.request = request;
      this.promise = promise;
    }
  }

  /**
   * @return if true this {@link Client }is reusable or false if when the channel was closed and we
   *     do not want to reconnect since the the {@link ClientState}'s worker group will be
   *     incorrect.
   */
  public boolean isReusable() {
    return manager.connectionState() != ClientConnectionState.CLOSED_CONNECTION;
  }
}