/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */
package org.apache.hc.client5.http.rest;

import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.ResponseProcessingException;
import jakarta.ws.rs.core.Response;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.message.BasicHttpRequest;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.http.message.MessageSupport;
import org.apache.hc.core5.http.nio.AsyncEntityProducer;
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer;
import org.apache.hc.core5.http.nio.entity.StringAsyncEntityConsumer;
import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer;
import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
import org.apache.hc.core5.jackson2.http.JsonObjectEntityProducer;
import org.apache.hc.core5.jackson2.http.JsonResponseConsumers;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.hc.core5.net.WWWFormCodec;
import org.apache.hc.core5.util.Args;

/**
 * {@link InvocationHandler} that translates interface method calls into HTTP requests
 * executed through the async {@link CloseableHttpAsyncClient} transport. Each method is
 * mapped to its HTTP verb, URI template and parameter bindings at proxy creation time.
 */
final class RestInvocationHandler implements InvocationHandler {

    private final CloseableHttpAsyncClient httpClient;
    private final URI baseUri;
    private final ObjectMapper objectMapper;
    private final Map<Method, ResourceMethod> methodMap;

    RestInvocationHandler(final CloseableHttpAsyncClient client, final URI base,
                          final Map<Method, ResourceMethod> methodMap,
                          final ObjectMapper mapper) {
        this.httpClient = client;
        this.baseUri = base;
        this.objectMapper = mapper;
        this.methodMap = methodMap;
    }

    @Override
    public Object invoke(final Object proxy, final Method method,
                         final Object[] args) throws Throwable {
        if (method.getDeclaringClass() == Object.class) {
            return handleObjectMethod(proxy, method, args);
        }
        final ResourceMethod resourceMethod = methodMap.get(method);
        if (resourceMethod == null) {
            throw new RestResourceException("No Jakarta REST mapping for " + method.getName());
        }
        return executeRequest(resourceMethod, args);
    }

    private Object executeRequest(final ResourceMethod rm,
                                  final Object[] args) throws Exception {
        final ResourceParam[] params = rm.getParams();
        final Map<String, String> pathParams = new HashMap<>(params.length);
        final List<NameValuePair> queryParams = new ArrayList<>(params.length);
        final List<Header> headers = new ArrayList<>(params.length);
        Object bodyParam = null;

        if (args != null) {
            for (int i = 0; i < params.length; i++) {
                final ResourceParam param = params[i];
                final String paramName = param.getName();
                final Object arg = args[i];
                final String paramValue = arg != null ? paramToString(arg) : param.getDefaultValue();
                switch (param.getType()) {
                    case PATH:
                        Args.check(paramValue != null, "Path parameter '%s' must not be null", param.getName());
                        pathParams.put(paramName, paramValue);
                        break;
                    case QUERY:
                        queryParams.add(new BasicNameValuePair(paramName, paramValue));
                        break;
                    case HEADER:
                        headers.add(new BasicHeader(paramName, paramValue));
                        break;
                    case BODY:
                        bodyParam = arg;
                        break;
                }
            }
        }
        final URI requestUri;
        try {
            final URIBuilder uriBuilder = new URIBuilder(baseUri);
            final List<PathSegment> pathTemplates = rm.getPathSegments();
            final List<String> pathSegments = new ArrayList<>(pathTemplates.size());
            for (final PathSegment pathTemplate : pathTemplates) {
                if (pathTemplate.getType() == PathSegment.Type.PARAMETER) {
                    pathSegments.add(pathParams.get(pathTemplate.getSegment()));
                } else {
                    pathSegments.add(pathTemplate.getSegment());
                }
            }
            uriBuilder.appendPathSegments(pathSegments);
            uriBuilder.addParameters(queryParams);
            requestUri = uriBuilder.build();
        } catch (final URISyntaxException ex) {
            throw new RestResourceException("Invalid request URI", ex);
        }
        final BasicHttpRequest request = new BasicHttpRequest(rm.getHttpMethod(), requestUri);
        for (final Header header : headers) {
            request.addHeader(header);
        }
        // @Produces declares the media type(s) the client accepts back (Accept header).
        final List<ContentType> acceptTypes = rm.getProducesContentTypes();
        if (acceptTypes != null && !acceptTypes.isEmpty()) {
            request.setHeader(MessageSupport.headerOfTokens(
                    HttpHeaders.ACCEPT,
                    acceptTypes.stream()
                    .map(ContentType::getMimeType)
                    .collect(Collectors.toList())));
        }
        // @Consumes declares the media type of the request body the client sends.
        final AsyncEntityProducer entityProducer;
        if (bodyParam != null) {
            final List<ContentType> requestBodyTypes = rm.getConsumesContentTypes();
            final ContentType contentType = requestBodyTypes != null && !requestBodyTypes.isEmpty() ?
                    requestBodyTypes.get(0) : null;
            entityProducer = createEntityProducer(bodyParam, contentType);
        } else {
            entityProducer = null;
        }

        final BasicRequestProducer requestProducer = new BasicRequestProducer(request, entityProducer);

        final boolean isAsync = isAsync(rm.getMethod());
        final Class<?> rawType = resolveResponseType(rm.getMethod(), isAsync);
        final Future<?> future = dispatchAsync(rm.getMethod(), isAsync, rawType, requestProducer);
        if (isAsync) {
            return future;
        }
        return awaitSync(future);
    }

    private static boolean isAsync(final Method method) {
        final Class<?> rt = method.getReturnType();
        return rt == CompletionStage.class || rt == CompletableFuture.class;
    }

    private static Class<?> resolveResponseType(final Method method, final boolean async) {
        if (!async) {
            return method.getReturnType();
        }
        final Type generic = method.getGenericReturnType();
        if (generic instanceof ParameterizedType) {
            final Type inner = ((ParameterizedType) generic).getActualTypeArguments()[0];
            if (inner instanceof Class) {
                return (Class<?>) inner;
            }
            if (inner instanceof ParameterizedType) {
                final Type raw = ((ParameterizedType) inner).getRawType();
                if (raw instanceof Class) {
                    return (Class<?>) raw;
                }
            }
        }
        return Object.class;
    }

    private CompletableFuture<?> dispatchAsync(final Method method, final boolean async,
                                               final Class<?> rawType,
                                               final BasicRequestProducer requestProducer) {
        if (rawType == Response.class) {
            return submit(requestProducer, new BasicResponseConsumer<>(new RestContentConsumer(objectMapper)))
                    .thenApply(result ->
                            new RestClientResponse(result.getHead(), result.getBody()));
        }
        if (rawType == void.class || rawType == Void.class) {
            return submit(requestProducer, new RestResponseConsumer<>(objectMapper, DiscardingEntityConsumer::new))
                    .thenApply(result -> {
                        throwIfError(result);
                        return null;
                    });
        }
        if (rawType == byte[].class) {
            return submit(requestProducer, new RestResponseConsumer<>(objectMapper, BasicAsyncEntityConsumer::new))
                    .thenApply(result -> {
                        throwIfError(result);
                        return result.getBody();
                    });
        }
        if (rawType == String.class) {
            return submit(requestProducer, new RestResponseConsumer<>(objectMapper, StringAsyncEntityConsumer::new))
                    .thenApply(result -> {
                        throwIfError(result);
                        return result.getBody();
                    });
        }
        if (isNameValuePairCollection(method, async)) {
            return submit(requestProducer, new RestResponseConsumer<>(objectMapper, StringAsyncEntityConsumer::new))
                    .thenApply(result -> {
                        throwIfError(result);
                        final Header header = result.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE);
                        final ContentType contentType = header != null
                                ? MessageSupport.parserHeaderValue(header, ContentType::parse)
                                : null;
                        if (contentType != null && !ContentType.APPLICATION_FORM_URLENCODED.isSameMimeType(contentType)) {
                            throw new RestResourceException(
                                    "Expected an application/x-www-form-urlencoded response but received: " + contentType.getMimeType());
                        }
                        final Charset charset = ContentType.getCharset(contentType, StandardCharsets.UTF_8);
                        final String body = result.getBody();
                        return WWWFormCodec.parse(body != null ? body : "", charset);
                    });
        }
        @SuppressWarnings("unchecked") final Class<Object> objectType = (Class<Object>) rawType;
        return submit(requestProducer,
                JsonResponseConsumers.create(objectMapper, objectType, () -> new RestContentConsumer(objectMapper)))
                .thenApply(result -> {
                    throwIfError(result);
                    return result.getBody();
                });
    }

    private static boolean isNameValuePairCollection(final Method method, final boolean async) {
        Type type = method.getGenericReturnType();
        if (async && type instanceof ParameterizedType) {
            type = ((ParameterizedType) type).getActualTypeArguments()[0];
        }
        if (!(type instanceof ParameterizedType)) {
            return false;
        }
        final ParameterizedType parameterizedType = (ParameterizedType) type;
        final Type rawType = parameterizedType.getRawType();
        if (!(rawType instanceof Class) || !Collection.class.isAssignableFrom((Class<?>) rawType)) {
            return false;
        }
        final Type[] typeArguments = parameterizedType.getActualTypeArguments();
        return typeArguments.length == 1 && typeArguments[0] == NameValuePair.class;
    }

    private <T> CompletableFuture<Message<HttpResponse, T>> submit(
            final BasicRequestProducer requestProducer,
            final AsyncResponseConsumer<Message<HttpResponse, T>> responseConsumer) {
        final CompletableFuture<Message<HttpResponse, T>> cf = new CompletableFuture<>();
        httpClient.execute(requestProducer, responseConsumer, null,
                new FutureCallback<>() {

                    @Override
                    public void completed(final Message<HttpResponse, T> result) {
                        cf.complete(result);
                    }

                    @Override
                    public void failed(final Exception ex) {
                        cf.completeExceptionally(ex);
                    }

                    @Override
                    public void cancelled() {
                        cf.cancel(false);
                    }
                });
        return cf;
    }

    private static Object awaitSync(final Future<?> future) throws Exception {
        try {
            return future.get();
        } catch (final ExecutionException | CompletionException ex) {
            throw unwrap(ex.getCause());
        } catch (final InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw ex;
        }
    }

    private static Exception unwrap(final Throwable cause) {
        if (cause instanceof Error) {
            // Do not mess with Errors
            throw (Error) cause;
        } else if (cause instanceof ResponseProcessingException) {
            return (ResponseProcessingException) cause;
        } else if (cause instanceof IOException) {
            return (IOException) cause;
        } else if (cause instanceof RuntimeException) {
            return (RuntimeException) cause;
        } else {
            throw new ProcessingException(cause);
        }
    }

    private AsyncEntityProducer createEntityProducer(final Object body,
                                                     final ContentType consumeType) {
        if (body instanceof byte[]) {
            final ContentType ct = consumeType != null ? consumeType : ContentType.APPLICATION_OCTET_STREAM;
            return new BasicAsyncEntityProducer((byte[]) body, ct);
        }
        if (body instanceof String) {
            final ContentType ct = consumeType != null ? consumeType : ContentType.TEXT_PLAIN;
            return new StringAsyncEntityProducer((CharSequence) body, ct);
        }
        if (body instanceof Collection) {
            final Collection<?> items = (Collection<?>) body;
            final boolean formByType = consumeType != null
                    && ContentType.APPLICATION_FORM_URLENCODED.isSameMimeType(consumeType);
            final boolean formByContent = consumeType == null
                    && !items.isEmpty()
                    && items.stream().allMatch(e -> e instanceof NameValuePair);
            if (formByType || formByContent) {
                final ContentType ct = formByType ? consumeType : ContentType.APPLICATION_FORM_URLENCODED;
                final Charset charset = ContentType.getCharset(ct, StandardCharsets.UTF_8);
                final List<NameValuePair> pairs = new ArrayList<>(items.size());
                for (final Object e : items) {
                    pairs.add((NameValuePair) e);
                }
                return new StringAsyncEntityProducer(WWWFormCodec.format(pairs, charset), ct);
            }
        }
        return new JsonObjectEntityProducer<>(body, objectMapper);
    }

    /**
     * Throws {@link ResponseProcessingException} if the message carries an error.
     */
    private static void throwIfError(final Message<HttpResponse, ?> result) {
        final HttpResponse httpResponse = result.head();
        final Object error = result.error();
        if (error != null) {
            final RestContent content;
            if (error instanceof RestContent) {
                content = (RestContent) error;
            } else {
                content = null;
            }
            throw new ResponseProcessingException(new RestClientResponse(httpResponse, content), "REST response processing error");
        }
    }

    /**
     * Converts a parameter value to its string representation for use in URI
     * path segments, query parameters or HTTP headers. Enums are converted using
     * {@link Enum#name()} to ensure round-trip compatibility with {@code valueOf}.
     */
    static String paramToString(final Object value) {
        if (value instanceof Enum) {
            return ((Enum<?>) value).name();
        }
        return value.toString();
    }

    private Object handleObjectMethod(final Object proxy, final Method method,
                                      final Object[] args) {
        final String name = method.getName();
        if ("toString".equals(name)) {
            return "RestProxy[" + baseUri + "]";
        }
        if ("hashCode".equals(name)) {
            return System.identityHashCode(proxy);
        }
        if ("equals".equals(name)) {
            return args[0] == proxy;
        }
        throw new UnsupportedOperationException(name);
    }

}