package com.hashicorp.nomad.javasdk;

import com.hashicorp.nomad.apimodel.Node;
import com.hashicorp.nomad.apimodel.NodeListStub;
import com.hashicorp.nomad.apimodel.OperatorHealthReply;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultConnectionKeepAliveStrategy;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpContext;

import javax.annotation.Nullable;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.ConnectException;
import java.util.List;

import static com.hashicorp.nomad.javasdk.NomadPredicates.isHealthy;
import static com.hashicorp.nomad.javasdk.NomadPredicates.hadKnownLeader;
import static com.hashicorp.nomad.javasdk.NomadPredicates.clientNodeIsReady;
import static com.hashicorp.nomad.javasdk.NomadPredicates.responseValue;
import static com.hashicorp.nomad.javasdk.NomadPredicates.both;

/**
 * An asynchronous client for the
 * <a href="https://www.nomadproject.io/docs/http">Nomad HTTP API</a>.
 */
public final class NomadApiClient implements Closeable, AutoCloseable {
    private NomadApiConfiguration config;
    private final CloseableHttpClient httpClient;

    /**
     * Creates an API client.
     *
     * @param address the scheme (http or https), host and port of the agent to connect to,
     *                e.g. "http://localhost:4646"
     */
    public NomadApiClient(final String address) {
        this(new NomadApiConfiguration.Builder().setAddress(address).build());
    }


    /**
     * Creates an API client.
     *
     * @param address the scheme (http or https), host and port of the agent to connect to
     */
    public NomadApiClient(final HttpHost address) {
        this(new NomadApiConfiguration.Builder().setAddress(address).build());
    }

    /**
     * Creates a new API client.
     *
     * @param config the configuration for the new API client
     */
    public NomadApiClient(NomadApiConfiguration config) {
        this(config, null);
    }

    /**
     * Creates an API client using the given configuration.
     *
     * @param config     configuration for the API
     * @param httpClient the HTTP to client to use.
     *                   If null, a client will be built using the provided API configuration.
     */
    public NomadApiClient(NomadApiConfiguration config, @Nullable CloseableHttpClient httpClient) {
        this.config = config;
        this.httpClient = httpClient != null ? httpClient : buildHttpClient(config);
    }

    /**
     * Returns the API client's configuration.
     */
    public NomadApiConfiguration getConfig() {
        return config;
    }

    /**
     * Closes the underlying HTTP client.
     */
    @Override
    public void close() throws IOException {
        httpClient.close();
    }

    /**
     * Returns an API for agent a cluster management.
     */
    public AgentApi getAgentApi() {
        return new AgentApi(this);
    }

    /**
     * Returns an API for querying information about allocations.
     */
    public AllocationsApi getAllocationsApi() {
        return new AllocationsApi(this);
    }

    /**
     * Returns an API for interacting directly a client node.
     *
     * @param node the client node to connect to
     */
    public ClientApi getClientApi(final Node node) {
        return getClientApi(HttpHost.create(
                node.getTlsEnabled()
                        ? "https://" + node.getHttpAddr()
                        : node.getHttpAddr()));
    }

    /**
     * Returns an API for interacting directly with a client node.
     *
     * @param nodeAddress the HTTP or HTTPS url for the client node in the format "scheme://host:port"
     */
    public ClientApi getClientApi(final HttpHost nodeAddress) {
        return new ClientApi(this, nodeAddress);
    }

    /**
     * Sets the active namespace of this API client.
     *
     * @param namespace the namespace to use
     */
    public void setNamespace(String namespace) {
        config = config.withNamespace(namespace);
    }

    /**
     * Sets the active ACL token secret ID that this client passes to the server.
     *
     * @param authToken the secret ID to use
     */
    public void setAuthToken(String authToken) {
        config = config.withAuthToken(authToken);
    }

    /**
     * Returns an API for interacting directly with a client node after looking up its address.
     *
     * @param nodeId the nodeId of the client node to connect to
     * @throws IOException    if there is an HTTP or lower-level problem
     * @throws NomadException if the response signals an error or cannot be deserialized
     */
    public ClientApi lookupClientApiByNodeId(String nodeId) throws IOException, NomadException {
        return getClientApi(getNodesApi().info(nodeId).getValue());
    }

    /**
     * Returns an API for managing ACL policies.
     */
    public AclPoliciesApi getAclPoliciesApi() {
        return new AclPoliciesApi(this);
    }

    /**
     * Returns an API for managing ACL tokens.
     */
    public AclTokensApi getAclTokensApi() {
        return new AclTokensApi(this);
    }

    /**
     * Returns an API for managing CSI plugins.
     */
    public CSIPluginsApi getCSIPluginsApi() {
        return new CSIPluginsApi(this);
    }

    /**
     * Returns an API for managing CSI volumes.
     */
    public CSIVolumesApi getCSIVolumesApi() {
        return new CSIVolumesApi(this);
    }

    /**
     * Returns an API for managing deployments.
     */
    public DeploymentsApi getDeploymentsApi() {
        return new DeploymentsApi(this);
    }

    /**
     * Returns an API for querying information about evaluations.
     */
    public EvaluationsApi getEvaluationsApi() {
        return new EvaluationsApi(this);
    }

    /**
     * Returns an API for submitting and managing jobs.
     */
    public JobsApi getJobsApi() {
        return new JobsApi(this);
    }

    /**
     * Returns an API for managing namespaces.
     */
    public NamespacesApi getNamespacesApi() {
        return new NamespacesApi(this);
    }

    /**
     * Returns an API for querying information about the client nodes in the Nomad cluster.
     */
    public NodesApi getNodesApi() {
        return new NodesApi(this);
    }

    /**
     * Returns an API for operating the Nomad cluster.
     */
    public OperatorApi getOperatorApi() {
        return new OperatorApi(this);
    }

    /**
     * Returns an API for managing quotas.
     */
    public QuotasApi getQuotasApi() {
        return new QuotasApi(this);
    }

    /**
     * Returns an API for listing the regions in the Nomad cluster.
     */
    public RegionsApi getRegionsApi() {
        return new RegionsApi(this);
    }

    /**
     * Returns an API for accessing scaling policies.
     */
    public ScalingApi getScalingApi() {
        return new ScalingApi(this);
    }

    /**
     * Returns an API for searching for items in Nomad cluster.
     */
    public SearchApi getSearchApi() {
        return new SearchApi(this);
    }

    /**
     * Returns an API for managing ACL policies.
     */
    public SentinelPoliciesApi getSentinelPoliciesApi() {
        return new SentinelPoliciesApi(this);
    }

    /**
     * Returns an API for querying the status of the Nomad cluster.
     */
    public StatusApi getStatusApi() {
        return new StatusApi(this);
    }

    /**
     * Returns an API for performing system maintenance operations on the Nomad cluster.
     */
    public SystemApi getSystemApi() {
        return new SystemApi(this);
    }

    /**
     * Polls until the server API on the remote agent is ready.
     * <p>
     * At a minimum, the API must be responding to server API requests.
     *
     * @param shouldHaveLeader if true, polling continues until there is a known leader.
     * @param clientName       if not null, polling until the client node with the given name is ready.
     * @param waitStrategy     the wait strategy to use for polling
     * @return a future that completes successfully when the API is ready
     * @throws IOException          if there is an HTTP or lower-level problem
     * @throws NomadException       if the response signals an error or cannot be deserialized
     * @throws InterruptedException if the thread is interrupted while waiting to poll again
     */
    public ServerQueryResponse<?> pollUntilServerIsReady(
            boolean shouldHaveLeader,
            @Nullable final String clientName,
            final WaitStrategy waitStrategy) throws IOException, NomadException, InterruptedException {

        Predicate<ServerQueryResponse<List<NodeListStub>>> predicate = null;

        if (shouldHaveLeader)
            predicate = hadKnownLeader();

        if (clientName != null) {
            Predicate<ServerQueryResponse<List<NodeListStub>>> clientIsReady =
                    responseValue(clientNodeIsReady(clientName));
            predicate = predicate == null ? clientIsReady : both(predicate, clientIsReady);
        }

        while (true) {
            try {
                return getNodesApi().list(
                        QueryOptions
                                .pollRepeatedlyUntil(predicate, waitStrategy)
                                .setAllowStale(true)
                );
            } catch (ConnectException e) {
                // we'll try again
            }
            Thread.sleep(100);
            waitStrategy.getWait();
        }
    }

    /**
     * Polls until the API on the remote agent is ready.
     * <p>
     * At a minimum, the API must be responding to API requests.
     *
     * @param waitStrategy the wait strategy to use while polling
     * @return a future that completes successfully when the API is ready
     * @throws IOException          if there is an HTTP or lower-level problem
     * @throws NomadException       if the response signals an error or cannot be deserialized
     * @throws InterruptedException if the thread is interrupted while waiting to poll again
     */
    public NomadResponse<?> pollUntilAgentIsReady(final WaitStrategy waitStrategy)
            throws IOException, NomadException, InterruptedException {

        while (true) {
            try {
                return getAgentApi().self();
            } catch (ConnectException e) {
                // we'll try again
            }
            Thread.sleep(100);
            waitStrategy.getWait();
        }
    }

    /**
     * Polls until the autopilot indicates that the cluster is healthy.
     * <p>
     * At a minimum, the API must be responding to API requests.
     *
     * @param waitStrategy the wait strategy to use while polling
     * @return a future that completes successfully when the API is ready
     * @throws IOException          if there is an HTTP or lower-level problem
     * @throws NomadException       if the response signals an error or cannot be deserialized
     * @throws InterruptedException if the thread is interrupted while waiting to poll again
     */
    public NomadResponse<?> pollUntilClusterHealthy(final WaitStrategy waitStrategy)
            throws IOException, NomadException, InterruptedException {

        Predicate<ServerQueryResponse<OperatorHealthReply>> predicate = isHealthy();

        while (true) {
            try {
                return getOperatorApi().getHealth(
                        QueryOptions
                                .pollRepeatedlyUntil(predicate, waitStrategy)
                                .setAllowStale(true)
                );
            } catch (ConnectException e) {
                // we'll try again
            } catch (ErrorResponseException e) {
                if (e.getServerErrorCode() != 429) {
                    throw e;
                }
            }
            Thread.sleep(100);
            waitStrategy.getWait();
        }
    }

    <R extends NomadResponse<?>> R execute(
            final RequestBuilder requestBuilder,
            final ResponseAdapter<?, R> responseAdapter,
            @Nullable final RequestOptions requestOptions
    ) throws IOException, NomadException {
        final HttpUriRequest request = buildRequest(requestBuilder, requestOptions);
        try (final CloseableHttpResponse response = httpClient.execute(request)) {
            if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                throw ErrorResponseException.signaledInStatus(request, response);
            }
            try {
                return responseAdapter.apply(response);
            } catch (ErrorFoundInResponseEntityException e) {
                throw ErrorResponseException.signaledInEntity(request, response, e.getMessage());
            }
        }
    }

    InputStream executeRawStream(
            final RequestBuilder requestBuilder,
            @Nullable final RequestOptions requestOptions
    )
            throws IOException, NomadException {

        final HttpUriRequest request = buildRequest(requestBuilder, requestOptions);
        CloseableHttpResponse response = httpClient.execute(request);
        try {
            if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                throw ErrorResponseException.signaledInStatus(request, response);
            }
            return response.getEntity().getContent();
        } catch (Throwable e) {
            response.close();
            throw e;
        }
    }

    FramedStream executeFramedStream(
            final RequestBuilder requestBuilder,
            @Nullable final RequestOptions requestOptions
    )
            throws IOException, NomadException {

        final HttpUriRequest request = buildRequest(requestBuilder, requestOptions);
        CloseableHttpResponse response = httpClient.execute(request);
        try {
            if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                throw ErrorResponseException.signaledInStatus(request, response);
            }
            return new FramedStream(response);
        } catch (Throwable e) {
            response.close();
            throw e;
        }
    }

    HttpHost getAddress() {
        return config.getAddress();
    }

    private HttpUriRequest buildRequest(
            RequestBuilder requestBuilder,
            @Nullable RequestOptions options
    ) {
        String region =    getConfig().getRegion();
        String namespace = getConfig().getNamespace();
        String authToken = getConfig().getAuthToken();

        if (options != null) {
            if (options.getRegion() != null)
                region = options.getRegion();
            if (options.getNamespace() != null)
                namespace = options.getNamespace();
            if (options.getAuthToken() != null)
                authToken = options.getAuthToken();
        }

        if (region != null && !region.isEmpty())
            requestBuilder.addParameter("region", region);
        if (namespace != null && !namespace.isEmpty())
            requestBuilder.addParameter("namespace", namespace);
        if (authToken != null && !authToken.isEmpty())
            requestBuilder.addHeader("X-Nomad-Token", authToken);

        return requestBuilder.build();
    }

    private CloseableHttpClient buildHttpClient(NomadApiConfiguration config) {

        return HttpClientBuilder.create()
                .setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy() {
                    @Override
                    public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
                        final long serverKeepAlive = super.getKeepAliveDuration(response, context);
                        return serverKeepAlive > 0 ? serverKeepAlive : 60000;
                    }
                })
                .setRetryHandler(new DefaultHttpRequestRetryHandler() {
                    @Override
                    protected boolean handleAsIdempotent(HttpRequest request) {
                        return true;
                    }
                })
                .setSSLContext(buildSslContext(config.getTls()))
                .setSSLHostnameVerifier(new NomadHostnameVerifier())
                .build();
    }

    private static SSLContext buildSslContext(NomadApiConfiguration.Tls tls) {
        try {
            SSLContext context = SSLContext.getInstance("TLS");

            KeyManager[] keyManagers = tls.getClientKeyFile() == null
                    ? null
                    : TlsUtils.usePemCertificateAndKey(tls.getClientCertificateFile(), tls.getClientKeyFile());

            TrustManager[] trustManagers = tls.isSkipVerify()
                    ? TlsUtils.trustAnyCertificate()
                    : tls.getCaCertificateFile() != null
                        ? TlsUtils.trustPemCertificate(tls.getCaCertificateFile())
                        : null;

            context.init(keyManagers, trustManagers, null);
            return context;
        } catch (Throwable e) {
            throw new RuntimeException("There was an error building the SSLContext: " + e, e);
        }
    }
}