/*
 * Copyright The OpenTelemetry Authors
 * SPDX-License-Identifier: Apache-2.0
 */

package io.opentelemetry.javaagent.instrumentation.finaglehttp.v23_11;

import static io.opentelemetry.javaagent.instrumentation.finaglehttp.v23_11.Utils.createClient;
import static org.assertj.core.api.Assertions.assertThat;

import com.twitter.finagle.ConnectionFailedException;
import com.twitter.finagle.Failure;
import com.twitter.finagle.ReadTimedOutException;
import com.twitter.finagle.Service;
import com.twitter.finagle.http.Request;
import com.twitter.finagle.http.Response;
import com.twitter.util.Await;
import com.twitter.util.Duration;
import com.twitter.util.Future;
import com.twitter.util.FuturePool;
import com.twitter.util.Time;
import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec;
import io.netty.handler.timeout.ReadTimeoutException;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
import io.opentelemetry.instrumentation.testing.junit.http.AbstractHttpClientTest;
import io.opentelemetry.instrumentation.testing.junit.http.HttpClientInstrumentationExtension;
import io.opentelemetry.instrumentation.testing.junit.http.HttpClientResult;
import io.opentelemetry.instrumentation.testing.junit.http.HttpClientTestOptions;
import io.opentelemetry.javaagent.instrumentation.finaglehttp.v23_11.Utils.ClientType;
import io.opentelemetry.semconv.ServerAttributes;
import java.net.ConnectException;
import java.net.URI;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.assertj.core.api.AbstractThrowableAssert;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.extension.RegisterExtension;

/**
 * Tests relevant client functionality.
 *
 * @implNote Why no http/2 tests: finagle maps everything down to http/1.1 via netty's own {@link
 *     Http2StreamFrameToHttpObjectCodec} which results in the same code path execution through
 *     finagle's netty stack. While testing would undoubtedly be beneficial, it's at this time
 *     untested due to lack of concrete support from the otel instrumentation test framework and
 *     upstream netty instrumentation, both.
 */
// todo implement http/2-specific tests;
//  otel test framework doesn't support an http/2 server out of the box
class ClientTest extends AbstractHttpClientTest<Request> {
  @RegisterExtension
  static final InstrumentationExtension testing = HttpClientInstrumentationExtension.forAgent();

  private final Map<ClientType, Service<Request, Response>> clients = new ConcurrentHashMap<>();

  // finagle Services are closeable, but are bound to a host + port;
  // as these are only known during the invocation of the test, each test must create and then
  // tear down their respective Services.
  //
  // however, the underlying netty bits are reused between Services by default, so "close"
  // works out to a more "virtual" operation than with other client libraries.
  @AfterEach
  void tearDown() throws Exception {
    for (Service<Request, Response> client : clients.values()) {
      Await.ready(client.close(Time.fromSeconds(10)));
    }
    clients.clear();
  }

  private Service<Request, Response> getClient(URI uri) {
    return getClient(uri, uri.getScheme().equals("https") ? ClientType.TLS : ClientType.DEFAULT);
  }

  private Service<Request, Response> getClient(URI uri, ClientType clientType) {
    return clients.computeIfAbsent(
        clientType,
        (type) -> createClient(type).newService(uri.getHost() + ":" + Utils.safePort(uri)));
  }

  private Future<Response> doSendRequest(Request request, URI uri) {
    // push this onto a FuturePool for 2 reasons:
    //  1) forces the request handling onto a different thread, ensuring test accuracy
    //  2) using the default thread can mess with high concurrency scenarios
    Context context = Context.current();
    return FuturePool.unboundedPool()
        .apply(
            () -> {
              try (Scope ignored = context.makeCurrent()) {
                return Await.result(getClient(uri).apply(request));
              } catch (Exception e) {
                throw new RuntimeException(e);
              }
            });
  }

  @Override
  protected void configure(HttpClientTestOptions.Builder optionsBuilder) {
    optionsBuilder.setSingleConnectionFactory(
        (host, port) -> {
          URI uri = URI.create(String.format("http://%s:%d", host, port));
          Service<Request, Response> svc = getClient(uri, ClientType.SINGLE_CONN);
          return (path, headers) -> {
            // this is synchronized bc so is the Netty one;
            // seems like the use of a "single" (presumably-queueing) connection would do this
            // automatically, but apparently not
            synchronized (svc) {
              Request get = buildRequest("GET", URI.create(uri + path), headers);
              return Await.result(svc.apply(get), Duration.fromSeconds(20)).statusCode();
            }
          };
        });

    optionsBuilder.setHttpAttributes(ClientTest::getHttpAttributes);
    optionsBuilder.setExpectedClientSpanNameMapper(ClientTest::getExpectedClientSpanName);
    optionsBuilder.disableTestRedirects();
    optionsBuilder.setClientSpanErrorMapper(
        (uri, error) -> {
          // all errors should be wrapped in RuntimeExceptions due to how we run things in
          // doSendRequest()
          AbstractThrowableAssert<?, ?> clientWrapAssert =
              assertThat(error).isInstanceOf(RuntimeException.class);
          if ("http://localhost:61/".equals(uri.toString())
              || "https://192.0.2.1/".equals(uri.toString())) {
            // finagle handles all these in com.twitter.finagle.netty4.ConnectionBuilder.build();
            // all errors emitted by the netty Bootstrap.connect() call are mapped to
            // twitter/finagle exceptions and handled accordingly;
            // namely, this means wrapping the root exception in a finagle
            // ConnectionFailedException
            // and then with a twitter Failure.rejected() call, resulting in the multiple nestings
            // of the root exception
            clientWrapAssert
                .cause()
                .isInstanceOf(Failure.class)
                .cause()
                .isInstanceOf(ConnectionFailedException.class)
                .cause()
                .isInstanceOf(ConnectException.class);
            error = error.getCause().getCause().getCause();
          } else if (uri.getPath().endsWith("/read-timeout")) {
            // not a connect() exception like the above, so is not wrapped as above;
            clientWrapAssert.cause().isInstanceOf(ReadTimedOutException.class);
            // however, this specific case results in a mapping from netty's ReadTimeoutException
            // to finagle's ReadTimedOutException in the finagle client code, losing all trace of
            // the original exception; so we must construct it manually here
            error = new ReadTimeoutException();
          }
          return error;
        });
  }

  @Override
  public Request buildRequest(String method, URI uri, Map<String, String> headers) {
    return Utils.buildRequest(method, uri, headers);
  }

  @Override
  public int sendRequest(Request request, String method, URI uri, Map<String, String> headers)
      throws Exception {
    return Await.result(doSendRequest(request, uri), Duration.fromSeconds(30)).statusCode();
  }

  @Override
  public void sendRequestWithCallback(
      Request request,
      String method,
      URI uri,
      Map<String, String> headers,
      HttpClientResult httpClientResult) {
    doSendRequest(request, uri)
        .onSuccess(
            r -> {
              httpClientResult.complete(r.statusCode());
              return null;
            })
        .onFailure(
            t -> {
              httpClientResult.complete(t);
              return null;
            });
  }

  private static Set<AttributeKey<?>> getHttpAttributes(URI uri) {
    String uriString = uri.toString();
    // http://localhost:61/ => unopened port, https://192.0.2.1/ => non routable address
    if ("http://localhost:61/".equals(uriString) || "https://192.0.2.1/".equals(uriString)) {
      return Collections.emptySet();
    }
    Set<AttributeKey<?>> attributes = new HashSet<>(HttpClientTestOptions.DEFAULT_HTTP_ATTRIBUTES);
    attributes.remove(ServerAttributes.SERVER_ADDRESS);
    attributes.remove(ServerAttributes.SERVER_PORT);
    return attributes;
  }

  // borrowed from AbstractNetty41ClientTest as finagle's underlying framework under test here is
  // netty
  private static String getExpectedClientSpanName(URI uri, String method) {
    switch (uri.toString()) {
      case "http://localhost:61/": // unopened port
      case "https://192.0.2.1/": // non routable address
        return "CONNECT";
      default:
        return HttpClientTestOptions.DEFAULT_EXPECTED_CLIENT_SPAN_NAME_MAPPER.apply(uri, method);
    }
  }
}
