/**
 * 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.
 */
package org.apache.cxf.jaxrs.client;

import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.net.URI;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;

import javax.xml.stream.XMLStreamWriter;

import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.client.InvocationCallback;
import jakarta.ws.rs.client.ResponseProcessingException;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.EntityTag;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.ext.ParamConverter;
import jakarta.ws.rs.ext.WriterInterceptor;
import org.apache.cxf.Bus;
import org.apache.cxf.common.logging.LogUtils;
import org.apache.cxf.common.util.PropertyUtils;
import org.apache.cxf.common.util.StringUtils;
import org.apache.cxf.endpoint.ClientLifeCycleManager;
import org.apache.cxf.endpoint.ConduitSelector;
import org.apache.cxf.endpoint.Endpoint;
import org.apache.cxf.endpoint.Retryable;
import org.apache.cxf.helpers.CastUtils;
import org.apache.cxf.interceptor.AbstractOutDatabindingInterceptor;
import org.apache.cxf.interceptor.Fault;
import org.apache.cxf.interceptor.InFaultChainInitiatorObserver;
import org.apache.cxf.interceptor.Interceptor;
import org.apache.cxf.interceptor.StaxInEndingInterceptor;
import org.apache.cxf.jaxrs.client.spec.ClientRequestFilterInterceptor;
import org.apache.cxf.jaxrs.client.spec.ClientResponseFilterInterceptor;
import org.apache.cxf.jaxrs.impl.MetadataMap;
import org.apache.cxf.jaxrs.impl.ResponseImpl;
import org.apache.cxf.jaxrs.impl.UriBuilderImpl;
import org.apache.cxf.jaxrs.model.ParameterType;
import org.apache.cxf.jaxrs.model.URITemplate;
import org.apache.cxf.jaxrs.provider.ProviderFactory;
import org.apache.cxf.jaxrs.utils.AnnotationUtils;
import org.apache.cxf.jaxrs.utils.ExceptionUtils;
import org.apache.cxf.jaxrs.utils.HttpUtils;
import org.apache.cxf.jaxrs.utils.InjectionUtils;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.cxf.message.Exchange;
import org.apache.cxf.message.ExchangeImpl;
import org.apache.cxf.message.Message;
import org.apache.cxf.message.MessageContentsList;
import org.apache.cxf.message.MessageImpl;
import org.apache.cxf.message.MessageUtils;
import org.apache.cxf.phase.AbstractPhaseInterceptor;
import org.apache.cxf.phase.Phase;
import org.apache.cxf.phase.PhaseChainCache;
import org.apache.cxf.phase.PhaseInterceptorChain;
import org.apache.cxf.phase.PhaseManager;
import org.apache.cxf.service.Service;
import org.apache.cxf.service.model.BindingOperationInfo;
import org.apache.cxf.transport.MessageObserver;
import org.apache.cxf.transport.http.HTTPConduit;
import org.jspecify.annotations.Nullable;

/**
 * Common proxy and http-centric client implementation
 *
 */
public abstract class AbstractClient implements Client {
    public static final String EXECUTOR_SERVICE_PROPERTY = "executorService";

    protected static final String REQUEST_CONTEXT = "RequestContext";
    protected static final String RESPONSE_CONTEXT = "ResponseContext";
    protected static final String KEEP_CONDUIT_ALIVE = "KeepConduitAlive";
    protected static final String HTTP_SCHEME = "http";

    private static final String ALLOW_EMPTY_PATH_VALUES = "allow.empty.path.template.value";
    private static final String PROXY_PROPERTY = "jaxrs.proxy";
    private static final String HEADER_SPLIT_PROPERTY = "org.apache.cxf.http.header.split";
    private static final String SERVICE_NOT_AVAIL_PROPERTY = "org.apache.cxf.transport.service_not_available";
    private static final String COMPLETE_IF_SERVICE_NOT_AVAIL_PROPERTY =
        "org.apache.cxf.transport.complete_if_service_not_available";

    private static final Logger LOG = LogUtils.getL7dLogger(AbstractClient.class);
    private static final Set<String> KNOWN_METHODS = new HashSet<>(
        Arrays.asList("GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"));

    protected ClientConfiguration cfg = new ClientConfiguration();
    private ClientState state;
    private final AtomicBoolean closed = new AtomicBoolean();
    private volatile ConfigurationReference configurationReference = new ConfigurationReference(cfg); 

    protected static final class ConfigurationReference {
        private final AtomicLong refCount = new AtomicLong(1);
        private final ClientConfiguration cfg;

        public ConfigurationReference(ClientConfiguration cfg) {
            this.cfg = cfg;
        }

        public ConfigurationReference acquire() {
            refCount.incrementAndGet();
            return this;
        }
        
        public long refCount() {
            return refCount.get();
        }

        public long release() {
            return refCount.decrementAndGet();
        }

        public ClientConfiguration getConfiguration() {
            return cfg;
        }
    }
    
    protected AbstractClient(ClientState initialState) {
        this.state = initialState;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client query(String name, Object...values) {
        addMatrixQueryParamsToBuilder(getCurrentBuilder(), name, ParameterType.QUERY, null, values);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client header(String name, Object... values) {
        if (values == null) {
            throw new IllegalArgumentException();
        }
        if (HttpHeaders.CONTENT_TYPE.equals(name)) {
            if (values.length > 1) {
                throw new IllegalArgumentException("Content-Type can have a single value only");
            }
            type(convertParamValue(values[0], null));
        } else {
            for (Object o : values) {
                possiblyAddHeader(name, convertParamValue(o, null));
            }
        }
        return this;
    }


    /**
     * {@inheritDoc}
     */
    @Override
    public Client headers(MultivaluedMap<String, String> map) {
        state.getRequestHeaders().putAll(map);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client accept(MediaType... types) {
        for (MediaType mt : types) {
            possiblyAddHeader(HttpHeaders.ACCEPT, JAXRSUtils.mediaTypeToString(mt));
        }
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client type(MediaType ct) {
        return type(JAXRSUtils.mediaTypeToString(ct));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client type(String type) {
        state.getRequestHeaders().putSingle(HttpHeaders.CONTENT_TYPE, type);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client accept(String... types) {
        for (String type : types) {
            possiblyAddHeader(HttpHeaders.ACCEPT, type);
        }
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client cookie(Cookie cookie) {
        possiblyAddHeader(HttpHeaders.COOKIE, cookie.toString());
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client authorization(Object auth) {
        String value = convertParamValue(auth, null);
        state.getRequestHeaders().putSingle(HttpHeaders.AUTHORIZATION, value);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client modified(Date date, boolean ifNot) {
        SimpleDateFormat dateFormat = HttpUtils.getHttpDateFormat();
        String hName = ifNot ? HttpHeaders.IF_UNMODIFIED_SINCE : HttpHeaders.IF_MODIFIED_SINCE;
        state.getRequestHeaders().putSingle(hName, dateFormat.format(date));
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client language(String language) {
        state.getRequestHeaders().putSingle(HttpHeaders.CONTENT_LANGUAGE, language);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client match(EntityTag tag, boolean ifNot) {
        String hName = ifNot ? HttpHeaders.IF_NONE_MATCH : HttpHeaders.IF_MATCH;
        state.getRequestHeaders().putSingle(hName, tag.toString());
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client acceptLanguage(String... languages) {
        for (String s : languages) {
            possiblyAddHeader(HttpHeaders.ACCEPT_LANGUAGE, s);
        }
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client acceptEncoding(String... encs) {
        for (String s : encs) {
            possiblyAddHeader(HttpHeaders.ACCEPT_ENCODING, s);
        }
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client encoding(String enc) {
        state.getRequestHeaders().putSingle(HttpHeaders.CONTENT_ENCODING, enc);
        return this;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public MultivaluedMap<String, String> getHeaders() {
        MultivaluedMap<String, String> map = new MetadataMap<>(false, true);
        map.putAll(state.getRequestHeaders());
        return map;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public URI getBaseURI() {
        return state.getBaseURI();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public URI getCurrentURI() {
        return getCurrentBuilder().clone().buildFromEncoded();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Response getResponse() {
        return state.getResponse();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Client reset() {
        state.reset();
        return this;
    }

    @SuppressWarnings("deprecation")
    @Override
    protected void finalize() throws Throwable {
        close();
        super.finalize();
    }
    
    @Override
    public void close() {
        if (closed.compareAndSet(false, true)) {
            if (cfg.getBus() == null) {
                return;
            }

            final ConfigurationReference reference = configurationReference;
            final boolean shutdownConfiguration = reference == null || reference.release() == 0L;
            if (shutdownConfiguration) {
                cfg.getEndpoint().getCleanupHooks().
                    forEach(c -> {
                        try {
                            c.close();
                        } catch (IOException e) {
                            //ignore
                        }
                    });
            }

            final ClientLifeCycleManager mgr = cfg.getBus().getExtension(ClientLifeCycleManager.class);
            if (null != mgr) {
                mgr.clientDestroyed(new FrontendClientAdapter(getConfiguration()));
            }

            if (shutdownConfiguration) {
                if (cfg.getConduitSelector() instanceof Closeable) {
                    try {
                        ((Closeable)cfg.getConduitSelector()).close();
                    } catch (IOException e) {
                        //ignore, we're destroying anyway
                    }
                } else {
                    cfg.getConduit().close();
                }
            }

            // reset state
            state.reset();

            if (shutdownConfiguration && cfg.isShutdownBusOnClose()) {
                cfg.getBus().shutdown(false);
            }

            state = null;
            cfg = null;
            configurationReference = null;
        }
    }

    public void removeAllHeaders() {
        state.getRequestHeaders().clear();
    }

    private void possiblyAddHeader(String name, String value) {
        if (!isDuplicate(name, value)) {
            state.getRequestHeaders().add(name, value);
        }
    }

    private boolean isDuplicate(String name, String value) {
        List<String> values = state.getRequestHeaders().get(name);
        return values != null && values.contains(value);
    }

    protected ClientState getState() {
        return state;
    }

    protected UriBuilder getCurrentBuilder() {
        return state.getCurrentBuilder();
    }

    protected void resetResponse() {
        state.setResponse(null);
    }

    protected void resetBaseAddress(URI uri) {
        state.setBaseURI(uri);
    }

    protected void resetCurrentBuilder(URI uri) {
        state.setCurrentBuilder(new UriBuilderImpl(uri));
    }

    protected MultivaluedMap<String, String> getTemplateParametersMap(URITemplate template,
                                                                      List<Object> values) {
        if (values != null && !values.isEmpty()) {
            List<String> vars = template.getVariables();
            MultivaluedMap<String, String> templatesMap = new MetadataMap<>(vars.size());
            for (int i = 0; i < vars.size(); i++) {
                if (i < values.size()) {
                    templatesMap.add(vars.get(i), values.get(i).toString());
                }
            }
            return templatesMap;
        }
        return null;
    }

    protected ResponseBuilder setResponseBuilder(Message outMessage, Exchange exchange) throws Exception {
        Response response = exchange.get(Response.class);
        if (response != null) {
            outMessage.getExchange().getInMessage().put(Message.PROTOCOL_HEADERS, response.getStringHeaders());
            return JAXRSUtils.fromResponse(JAXRSUtils.copyResponseIfNeeded(response));
        }

        Integer status = getResponseCode(exchange);
        ResponseBuilder currentResponseBuilder = JAXRSUtils.toResponseBuilder(status);

        Message responseMessage = exchange.getInMessage() != null
            ? exchange.getInMessage() : exchange.getInFaultMessage();
        // if there is no response message, we just send the response back directly
        if (responseMessage == null) {
            return currentResponseBuilder;
        }
        
        final String reasonPhrase = (String)responseMessage.get(HTTPConduit.HTTP_RESPONSE_MESSAGE);
        if (reasonPhrase != null) {
            currentResponseBuilder.status(status, reasonPhrase);
        }

        Map<String, List<Object>> protocolHeaders =
            CastUtils.cast((Map<?, ?>)responseMessage.get(Message.PROTOCOL_HEADERS));

        boolean splitHeaders = MessageUtils.getContextualBoolean(outMessage, HEADER_SPLIT_PROPERTY);
        for (Map.Entry<String, List<Object>> entry : protocolHeaders.entrySet()) {
            if (null == entry.getKey()) {
                continue;
            }
            if (!entry.getValue().isEmpty()) {
                if (HttpUtils.isDateRelatedHeader(entry.getKey())) {
                    currentResponseBuilder.header(entry.getKey(), entry.getValue().get(0));
                    continue;
                }
                entry.getValue().forEach(valObject -> {
                    if (splitHeaders && valObject instanceof String) {
                        String val = (String) valObject;
                        final String[] values;
                        if (val.isEmpty()) {
                            values = new String[]{""};
                        } else if (val.charAt(0) == '"' && val.charAt(val.length() - 1) == '"') {
                            // if the value starts with a quote and ends with a quote, we do a best
                            // effort attempt to determine what the individual values are.
                            values = parseQuotedHeaderValue(val);
                        } else {
                            boolean splitPossible = !(HttpHeaders.SET_COOKIE.equalsIgnoreCase(entry.getKey())
                                    && val.toUpperCase().contains(HttpHeaders.EXPIRES.toUpperCase()));
                            values = splitPossible ? val.split(",") : new String[]{val};
                        }
                        for (String s : values) {
                            String theValue = s.trim();
                            if (!theValue.isEmpty()) {
                                currentResponseBuilder.header(entry.getKey(), theValue);
                            }
                        }
                    } else {
                        currentResponseBuilder.header(entry.getKey(), valObject);
                    }
                });
            }
        }
        String ct = (String)responseMessage.get(Message.CONTENT_TYPE);
        if (ct != null) {
            currentResponseBuilder.type(ct);
        }
        InputStream mStream = responseMessage.getContent(InputStream.class);
        currentResponseBuilder.entity(mStream);
        

        return currentResponseBuilder;
    }

    protected <T> void writeBody(T o, Message outMessage, Class<?> cls, Type type, Annotation[] anns,
                                 OutputStream os) {

        if (o == null) {
            return;
        }
        @SuppressWarnings("unchecked")
        MultivaluedMap<String, Object> headers =
            (MultivaluedMap<String, Object>)outMessage.get(Message.PROTOCOL_HEADERS);

        @SuppressWarnings("unchecked")
        Class<T> theClass = (Class<T>)cls;

        Object contentTypeHeader = headers.getFirst(HttpHeaders.CONTENT_TYPE);
        if (contentTypeHeader == null) {
            contentTypeHeader = MediaType.WILDCARD;
        }
        MediaType contentType = JAXRSUtils.toMediaType(contentTypeHeader.toString());

        List<WriterInterceptor> writers = ClientProviderFactory.getInstance(outMessage)
            .createMessageBodyWriterInterceptor(theClass, type, anns, contentType, outMessage, null);
        if (writers != null) {
            try {
                JAXRSUtils.writeMessageBody(writers,
                                            o,
                                            theClass,
                                            type,
                                            anns,
                                            contentType,
                                            headers,
                                            outMessage);

                OutputStream realOs = outMessage.get(OutputStream.class);
                if (realOs != null) {
                    realOs.flush();
                }
            } catch (Exception ex) {
                reportMessageHandlerProblem("MSG_WRITER_PROBLEM", cls, contentType, ex);
            }
        } else {
            reportMessageHandlerProblem("NO_MSG_WRITER", cls, contentType, null);
        }

    }

    protected WebApplicationException convertToWebApplicationException(Response r) {
        return ExceptionUtils.toWebApplicationException(r);
    }

    protected <T> T readBody(Response r, Message outMessage, Class<T> cls,
                             Type type, Annotation[] anns) {

        if (cls == Response.class) {
            return cls.cast(r);
        }

        int status = r.getStatus();
        if ((status < 200 || status == 204) && r.getLength() <= 0 || (status >= 300 && status != 304)) {
            return null;
        }
        return ((ResponseImpl)r).doReadEntity(cls, type, anns);
    }

    protected boolean responseStreamCanBeClosed(Message outMessage, Class<?> cls) {
        return !JAXRSUtils.isStreamingOutType(cls)
            && MessageUtils.getContextualBoolean(outMessage, ResponseImpl.RESPONSE_STREAM_AUTO_CLOSE);
    }

    protected void completeExchange(Exchange exchange, boolean proxy) {
        // higher level conduits such as FailoverTargetSelector need to
        // clear the request state but a fair number of response objects
        // depend on InputStream being still open thus lower-level conduits
        // operating on InputStream don't have to close streams pro-actively
        exchange.put(KEEP_CONDUIT_ALIVE, true);
        getConfiguration().getConduitSelector().complete(exchange);
        String s = (String)exchange.getOutMessage().get(Message.BASE_PATH);
        if (s != null && !state.getBaseURI().toString().equals(s)) {
            // usually the (failover) conduit change will result in a retry call
            // which in turn will reset the base and current request URI.
            // In some cases, such as the "upfront" load-balancing, etc, the retries
            // won't be executed so it is necessary to reset the base address
            calculateNewRequestURI(URI.create(s), getCurrentURI(), proxy);
            return;
        }
        s = (String)exchange.getOutMessage().get("transport.retransmit.url");
        if (s != null && !state.getBaseURI().toString().equals(s)) {
            calculateNewRequestURI(URI.create(s), getCurrentURI(), proxy);
        }
    }

    protected Object[] preProcessResult(Message message) throws Exception {
        Exchange exchange = message.getExchange();

        Exception ex = message.getContent(Exception.class);
        if (ex == null) {
            ex = message.getExchange().get(Exception.class);
        }
        if (ex == null && !exchange.isOneWay()) {
            synchronized (exchange) {
                while (exchange.get("IN_CHAIN_COMPLETE") == null) {
                    exchange.wait(cfg.getSynchronousTimeout());
                }
            }
        }
        if (ex == null) {
            ex = message.getContent(Exception.class);
        }
        if (ex != null
            || PropertyUtils.isTrue(exchange.get(SERVICE_NOT_AVAIL_PROPERTY))
                && PropertyUtils.isTrue(exchange.get(COMPLETE_IF_SERVICE_NOT_AVAIL_PROPERTY))) {
            getConfiguration().getConduitSelector().complete(exchange);
        }
        if (ex != null) {
            checkClientException(message, ex);
        }
        checkClientException(message, exchange.get(Exception.class));

        List<?> result = exchange.get(List.class);
        return result != null ? result.toArray() : null;
    }

    protected void checkClientException(Message outMessage, Exception ex) throws Exception {
        Throwable actualEx = ex instanceof Fault ? ((Fault)ex).getCause() : ex;

        Exchange exchange = outMessage.getExchange();
        Integer responseCode = getResponseCode(exchange);
        if (actualEx instanceof ResponseProcessingException) {
            throw (ResponseProcessingException)actualEx;
        } else if (responseCode == null
            || responseCode < 300 && !(actualEx instanceof IOException)
            || actualEx instanceof IOException && exchange.get("client.redirect.exception") != null) {
            if (actualEx instanceof ProcessingException) {
                throw (RuntimeException)actualEx;
            } else if (actualEx != null) {
                Object useProcExProp = exchange.get("wrap.in.processing.exception");
                if (actualEx instanceof RuntimeException
                    && useProcExProp != null && PropertyUtils.isFalse(useProcExProp)) {
                    throw (Exception)actualEx;
                }
                throw new ProcessingException(actualEx);
            } else if (!exchange.isOneWay() || cfg.isResponseExpectedForOneway()) {
                waitForResponseCode(exchange);
            }
        }
    }

    protected void waitForResponseCode(Exchange exchange) {
        synchronized (exchange) {
            if (getResponseCode(exchange) == null) {
                try {
                    exchange.wait(cfg.getSynchronousTimeout());
                } catch (InterruptedException ex) {
                    // ignore
                }
            } else {
                return;
            }
        }

        if (getResponseCode(exchange) == null) {
            throw new ProcessingException("Response timeout");
        }
    }

    private Integer getResponseCode(Exchange exchange) {
        Integer responseCode = (Integer)exchange.get(Message.RESPONSE_CODE);
        if (responseCode == null && exchange.getInMessage() != null) {
            responseCode = (Integer)exchange.getInMessage().get(Message.RESPONSE_CODE);
        }
        if (responseCode == null && exchange.isOneWay() && !state.getBaseURI().toString().startsWith("http")) {
            responseCode = 202;
        }
        return responseCode;
    }


    protected URI calculateNewRequestURI(Map<String, Object> reqContext) {
        URI newBaseURI = URI.create(reqContext.get(Message.ENDPOINT_ADDRESS).toString());
        URI requestURI = URI.create(reqContext.get(Message.REQUEST_URI).toString());
        return calculateNewRequestURI(newBaseURI, requestURI,
                PropertyUtils.isTrue(reqContext.get(PROXY_PROPERTY)));
    }

    private URI calculateNewRequestURI(URI newBaseURI, URI requestURI, boolean proxy) {
        String baseURIPath = newBaseURI.getRawPath();
        String reqURIPath = requestURI.getRawPath();

        UriBuilder builder = new UriBuilderImpl().uri(newBaseURI);
        String basePath = reqURIPath.startsWith(baseURIPath) ? baseURIPath : getBaseURI().getRawPath();
        String relativePath = reqURIPath.equals(basePath) ? ""
                : reqURIPath.startsWith(basePath) ? reqURIPath.substring(basePath.length()) : reqURIPath;
        builder.path(relativePath);

        String newQuery = newBaseURI.getRawQuery();
        if (newQuery == null) {
            builder.replaceQuery(requestURI.getRawQuery());
        } else {
            builder.replaceQuery(newQuery);
        }

        URI newRequestURI = builder.build();

        resetBaseAddress(newBaseURI);
        URI current = proxy ? newBaseURI : newRequestURI;
        resetCurrentBuilder(current);

        return newRequestURI;
    }

    protected void doRunInterceptorChain(Message m) {
        try {
            m.getInterceptorChain().doIntercept(m);
        } catch (Exception ex) {
            m.setContent(Exception.class, ex);
        }
    }

    @SuppressWarnings("unchecked")
    protected Object[] retryInvoke(BindingOperationInfo oi, Object[] params, Map<String, Object> context,
                              Exchange exchange) throws Exception {

        try {
            Object body = params.length == 0 ? null : params[0];
            Map<String, Object> reqContext = CastUtils.cast((Map<?, ?>)context.get(REQUEST_CONTEXT));
            MultivaluedMap<String, String> headers =
                (MultivaluedMap<String, String>)reqContext.get(Message.PROTOCOL_HEADERS);

            URI newRequestURI = calculateNewRequestURI(reqContext);
            // TODO: if failover conduit selector fails to find a failover target
            // then it will revert to the previous endpoint; that is not very likely
            // but possible - thus ideally we need to resert base and current URI only
            // if we get the same ConduitInitiatior endpoint instance before and after
            // retryInvoke.
            Object response = retryInvoke(newRequestURI, headers, body, exchange, context);
            exchange.put(List.class, getContentsList(response));
            return new Object[]{response};
        } catch (Exception ex) {
            exchange.put(Exception.class, ex);
        } catch (Throwable t) {
            exchange.put(Exception.class, new Exception(t));
        }
        return null;
    }

    protected abstract Object retryInvoke(URI newRequestURI,
                                 MultivaluedMap<String, String> headers,
                                 Object body,
                                 Exchange exchange,
                                 Map<String, Object> invContext) throws Throwable;


    protected void addMatrixQueryParamsToBuilder(UriBuilder ub,
                                                 String paramName,
                                                 ParameterType pt,
                                                 Annotation[] anns,
                                                 Object... pValues) {
        if (pt != ParameterType.MATRIX && pt != ParameterType.QUERY) {
            throw new IllegalArgumentException("This method currently deal "
                                               + "with matrix and query parameters only");
        }
        if (!"".equals(paramName)) {
            if (pValues != null && pValues.length > 0) {
                for (Object pValue : pValues) {
                    if (InjectionUtils.isSupportedCollectionOrArray(pValue.getClass())) {
                        Collection<?> c = pValue.getClass().isArray()
                            ? Arrays.asList((Object[]) pValue) : (Collection<?>) pValue;
                        for (Iterator<?> it = c.iterator(); it.hasNext();) {
                            convertMatrixOrQueryToBuilder(ub, paramName, it.next(), pt, anns);
                        }
                    } else {
                        convertMatrixOrQueryToBuilder(ub, paramName, pValue, pt, anns);
                    }
                }
            } else {
                addMatrixOrQueryToBuilder(ub, paramName, pt, pValues);
            }
        } else if (pValues != null && pValues.length > 0) {
            Object pValue = pValues[0];
            MultivaluedMap<String, Object> values = InjectionUtils.extractValuesFromBean(pValue, "");
            values.forEach((key, value) -> {
                value.forEach(v -> {
                    convertMatrixOrQueryToBuilder(ub, key, v, pt, anns);
                });
            });
        }
    }

    private void convertMatrixOrQueryToBuilder(UriBuilder ub,
                                           String paramName,
                                           Object pValue,
                                           ParameterType pt,
                                           Annotation[] anns) {
        Object convertedValue = convertParamValue(pValue, anns);
        addMatrixOrQueryToBuilder(ub, paramName, pt, convertedValue);
    }

    private void addMatrixOrQueryToBuilder(UriBuilder ub,
                                           String paramName,
                                           ParameterType pt,
                                           Object... pValue) {
        if (pt == ParameterType.MATRIX) {
            ub.matrixParam(paramName, pValue);
        } else {
            ub.queryParam(paramName, pValue);
        }
    }


    protected String convertParamValue(Object pValue, Annotation[] anns) {
        return convertParamValue(pValue, pValue == null ? null : pValue.getClass(), anns);
    }
    protected String convertParamValue(Object pValue, Class<?> pClass, Annotation[] anns) {
        if (pValue == null && pClass == null) {
            return null;
        }
        ProviderFactory pf = ClientProviderFactory.getInstance(cfg.getEndpoint());
        if (pf != null) {
            Message m = null;
            if (pf.isParamConverterContextsAvailable()) {
                m = new MessageImpl();
                m.put(Message.REQUESTOR_ROLE, Boolean.TRUE);
                m.setExchange(new ExchangeImpl());
                m.getExchange().setOutMessage(m);
                m.getExchange().put(Endpoint.class, cfg.getEndpoint());
            }
            @SuppressWarnings("unchecked")
            ParamConverter<Object> prov =
                (ParamConverter<Object>)pf.createParameterHandler(pClass, pClass, anns, m);
            if (prov != null) {
                try {
                    return prov.toString(pValue);
                } finally {
                    if (m != null) {
                        pf.clearThreadLocalProxies();
                    }
                }
            }
        }
        final String v = pValue == null ? null : pValue.toString();
        if (anns != null && StringUtils.isEmpty(v)) {
            final PathParam pp = AnnotationUtils.getAnnotation(anns, PathParam.class);
            if (null != pp) {
                Object allowEmptyProp = getConfiguration().getBus().getProperty(ALLOW_EMPTY_PATH_VALUES);
                if (!PropertyUtils.isTrue(allowEmptyProp)) {
                    throw new IllegalArgumentException("Value for " + pp.value() + " is not specified");
                }
            }
        }
        return v;
    }

    protected static void reportMessageHandlerProblem(String name, Class<?> cls, MediaType ct, Throwable ex) {
        String errorMessage = JAXRSUtils.logMessageHandlerProblem(name, cls, ct);
        Throwable actualEx = ex instanceof Fault ? ((Fault)ex).getCause() : ex;
        throw new ProcessingException(errorMessage, actualEx);
    }

    protected String[] parseQuotedHeaderValue(String originalValue) {
        // this algorithm isn't perfect; see CXF-3518 for further discussion.
        List<String> results = new ArrayList<>();
        char[] chars = originalValue.toCharArray();

        int lastIndex = chars.length - 1;

        boolean quote = false;
        final StringBuilder sb = new StringBuilder();

        for (int pos = 0; pos <= lastIndex; pos++) {
            final char c = chars[pos];
            if (pos == lastIndex) {
                sb.append(c);
                results.add(sb.toString());
            } else {
                switch(c) {
                case '\"':
                    sb.append(c);
                    quote = !quote;
                    break;
                case '\\':
                    if (!quote) {
                        sb.append(c);
                    }
                    break;
                case ',':
                    if (quote) {
                        sb.append(c);
                    } else {
                        results.add(sb.toString());
                        sb.setLength(0);
                    }
                    break;
                default:
                    sb.append(c);
                }
            }
        }
        return results.toArray(new String[0]);
    }

    public ClientConfiguration getConfiguration() {
        return cfg;
    }

    protected void setConfiguration(ClientConfiguration config) {
        cfg = config;
        configurationReference = new ConfigurationReference(config);
    }

    // Note that some conduit selectors may update Message.ENDPOINT_ADDRESS
    // after the conduit selector has been prepared but before the actual
    // invocation thus it is also important to have baseURI and currentURI
    // synched up with the latest endpoint address, after a successful proxy
    // or web client invocation has returned
    protected void prepareConduitSelector(Message message, URI currentURI, boolean proxy) {
        try {
            cfg.prepareConduitSelector(message);

        } catch (Fault ex) {
            LOG.warning("Failure to prepare a message from conduit selector");
        }
        message.getExchange().put(ConduitSelector.class, cfg.getConduitSelector());
        message.getExchange().put(Service.class, cfg.getConduitSelector().getEndpoint().getService());

        String address = (String)message.get(Message.ENDPOINT_ADDRESS);
        // custom conduits may override the initial/current address
        if (address.startsWith(HTTP_SCHEME) && !address.equals(currentURI.toString())) {
            URI baseAddress = URI.create(address);
            currentURI = calculateNewRequestURI(baseAddress, currentURI, proxy);
            message.put(Message.ENDPOINT_ADDRESS, currentURI.toString());
            message.put(Message.REQUEST_URI, currentURI.toString());
        }
        message.put(Message.BASE_PATH, getBaseURI().toString());
    }

    protected static PhaseInterceptorChain setupOutInterceptorChain(ClientConfiguration cfg) {
        PhaseManager pm = cfg.getBus().getExtension(PhaseManager.class);
        List<Interceptor<? extends Message>> i1 = cfg.getBus().getOutInterceptors();
        List<Interceptor<? extends Message>> i2 = cfg.getOutInterceptors();
        List<Interceptor<? extends Message>> i3 = cfg.getConduitSelector().getEndpoint().getOutInterceptors();
        PhaseInterceptorChain chain = new PhaseChainCache().get(pm.getOutPhases(), i1, i2, i3);
        chain.add(new ClientRequestFilterInterceptor());
        return chain;
    }

    protected static PhaseInterceptorChain setupInInterceptorChain(ClientConfiguration cfg) {
        PhaseManager pm = cfg.getBus().getExtension(PhaseManager.class);
        List<Interceptor<? extends Message>> i1 = cfg.getBus().getInInterceptors();
        List<Interceptor<? extends Message>> i2 = cfg.getInInterceptors();
        List<Interceptor<? extends Message>> i3 = cfg.getConduitSelector().getEndpoint().getInInterceptors();
        PhaseInterceptorChain chain = new PhaseChainCache().get(pm.getInPhases(), i1, i2, i3);
        chain.add(new ClientResponseFilterInterceptor());
        chain.setFaultObserver(setupInFaultObserver(cfg));
        return chain;
    }

    protected static MessageObserver setupInFaultObserver(final ClientConfiguration cfg) {
        return new InFaultChainInitiatorObserver(cfg.getBus()) {

            @Override
            protected void initializeInterceptors(Exchange ex, PhaseInterceptorChain chain) {
                chain.add(cfg.getInFaultInterceptors());
                chain.add(new ConnectionFaultInterceptor());
            }
        };
    }

    protected void setSupportOnewayResponseProperty(Message outMessage) {
        // Do propagate the response down to observer chain
        outMessage.put(Message.PROPAGATE_202_RESPONSE_ONEWAY_OR_PARTIAL, true);
        if (!outMessage.getExchange().isOneWay()) {
            outMessage.put(Message.PROCESS_ONEWAY_RESPONSE, true);
        }
    }
    protected void checkClosed() {
        if (closed.get()) {
            throw new IllegalStateException("Client is closed");
        }
    }

    protected Message createMessage(Object body,
                                    String httpMethod,
                                    MultivaluedMap<String, String> headers,
                                    URI currentURI,
                                    Exchange exchange,
                                    Map<String, Object> invocationContext,
                                    boolean proxy) {
        checkClosed();
        Message m = cfg.getConduitSelector().getEndpoint().getBinding().createMessage();
        m.put(Message.REQUESTOR_ROLE, Boolean.TRUE);
        m.put(Message.INBOUND_MESSAGE, Boolean.FALSE);

        setRequestMethod(m, httpMethod);
        m.put(Message.PROTOCOL_HEADERS, headers);
        if (currentURI.isAbsolute() && currentURI.getScheme().startsWith(HTTP_SCHEME)) {
            m.put(Message.ENDPOINT_ADDRESS, currentURI.toString());
        } else {
            m.put(Message.ENDPOINT_ADDRESS, state.getBaseURI().toString());
        }

        Object requestURIProperty = cfg.getRequestContext().get(Message.REQUEST_URI);
        if (requestURIProperty == null) {
            m.put(Message.REQUEST_URI, currentURI.toString());
        } else {
            m.put(Message.REQUEST_URI, requestURIProperty.toString());
        }

        String ct = headers.getFirst(HttpHeaders.CONTENT_TYPE);
        m.put(Message.CONTENT_TYPE, ct);

        body = checkIfBodyEmpty(body, ct);
        setEmptyRequestPropertyIfNeeded(m, body);

        m.setContent(List.class, getContentsList(body));

        m.put(URITemplate.TEMPLATE_PARAMETERS, getState().getTemplates());

        PhaseInterceptorChain chain = setupOutInterceptorChain(cfg);
        chain.setFaultObserver(setupInFaultObserver(cfg));
        m.setInterceptorChain(chain);

        exchange = createExchange(m, exchange);
        exchange.put(Message.REST_MESSAGE, Boolean.TRUE);
        exchange.setOneWay("true".equals(headers.getFirst(Message.ONE_WAY_REQUEST)));
        exchange.put(Retryable.class, new RetryableImpl());

        // context
        setContexts(m, exchange, invocationContext, proxy);

        //setup conduit selector
        prepareConduitSelector(m, currentURI, proxy);

        return m;
    }

    private void setRequestMethod(Message m, String httpMethod) {
        m.put(Message.HTTP_REQUEST_METHOD, httpMethod);
        if (!KNOWN_METHODS.contains(httpMethod)) {
            if (!m.containsKey("use.async.http.conduit")) {
                // if the async conduit is loaded then let it handle this method without users
                // having to explicitly request it given that, without reflectively updating
                // HTTPUrlConnection, it will not work without the async conduit anyway
                m.put("use.async.http.conduit", true);
            }

            if (!m.containsKey("use.httpurlconnection.method.reflection")) {
                // if the async conduit is not loaded then the only way for the custom HTTP verb
                // to be supported is to attempt to reflectively modify HTTPUrlConnection
                m.put("use.httpurlconnection.method.reflection", true);
            }
        }
    }

    protected void setEmptyRequestPropertyIfNeeded(Message outMessage, Object body) {
        if (body == null) {
            outMessage.put("org.apache.cxf.empty.request", true);
        }
    }


    protected Object checkIfBodyEmpty(Object body, String contentType) {
        //CHECKSTYLE:OFF
        if (body != null
            && (body.getClass() == String.class && ((String)body).isEmpty()
            || body.getClass() == Form.class && ((Form)body).asMap().isEmpty()
            || Map.class.isAssignableFrom(body.getClass()) && ((Map<?, ?>)body).isEmpty()
                && !MediaType.APPLICATION_JSON.equals(contentType)
            || body instanceof byte[] && ((byte[])body).length == 0)) {
            body = null;
        }
        //CHECKSTYLE:ON
        return body;
    }

    protected Map<String, Object> getRequestContext(Message outMessage) {
        Map<String, Object> invContext
            = CastUtils.cast((Map<?, ?>)outMessage.get(Message.INVOCATION_CONTEXT));
        return CastUtils.cast((Map<?, ?>)invContext.get(REQUEST_CONTEXT));
    }

    protected List<?> getContentsList(Object body) {
        return body == null ? new MessageContentsList() : new MessageContentsList(body);
    }

    protected Exchange createExchange(Message m, Exchange exchange) {
        if (exchange == null) {
            exchange = new ExchangeImpl();
        }
        exchange.setSynchronous(true);
        exchange.setOutMessage(m);
        exchange.put(Bus.class, cfg.getBus());
        exchange.put(MessageObserver.class, new ClientMessageObserver(cfg));
        exchange.put(Endpoint.class, cfg.getConduitSelector().getEndpoint());
        exchange.put("org.apache.cxf.transport.no_io_exceptions", true);
        //REVISIT - when response handling is actually put onto the in chain, this will likely not be needed
        exchange.put(StaxInEndingInterceptor.STAX_IN_NOCLOSE, Boolean.TRUE);
        m.setExchange(exchange);
        return exchange;
    }

    protected void setAsyncMessageObserverIfNeeded(Exchange exchange) {
        if (!exchange.isSynchronous()) {
            ExecutorService executor = (ExecutorService)cfg.getRequestContext().get(EXECUTOR_SERVICE_PROPERTY);
            if (executor != null) {
                exchange.put(Executor.class, executor);

                final ClientMessageObserver observer = new ClientMessageObserver(cfg);

                exchange.put(MessageObserver.class, message -> {
                    if (!message.getExchange().containsKey(Executor.class.getName() + ".USING_SPECIFIED")) {
                        executor.execute(() -> {
                            observer.onMessage(message);
                        });
                    } else {
                        observer.onMessage(message);
                    }
                });
            }
        }
    }

    protected void setContexts(Message message, Exchange exchange,
                               Map<String, Object> context, boolean proxy) {
        if (context == null) {
            context = new HashMap<>();
        }
        Map<String, Object> reqContext = CastUtils.cast((Map<?, ?>)context.get(REQUEST_CONTEXT));
        Map<String, Object> resContext = CastUtils.cast((Map<?, ?>)context.get(RESPONSE_CONTEXT));
        if (reqContext == null) {
            reqContext = new HashMap<>(cfg.getRequestContext());
            context.put(REQUEST_CONTEXT, reqContext);
        }
        reqContext.put(Message.PROTOCOL_HEADERS, message.get(Message.PROTOCOL_HEADERS));
        reqContext.put(Message.REQUEST_URI, message.get(Message.REQUEST_URI));
        reqContext.put(Message.ENDPOINT_ADDRESS, message.get(Message.ENDPOINT_ADDRESS));
        reqContext.put(PROXY_PROPERTY, proxy);

        if (resContext == null) {
            resContext = new HashMap<>();
            context.put(RESPONSE_CONTEXT, resContext);
        }

        message.put(Message.INVOCATION_CONTEXT, context);
        message.putAll(reqContext);
        exchange.putAll(reqContext);
    }

    protected void setPlainOperationNameProperty(Message outMessage, String name) {
        outMessage.getExchange().put("org.apache.cxf.resource.operation.name", name);
    }

    protected static Type getCallbackType(InvocationCallback<?> callback) {
        Class<?> cls = callback.getClass();
        ParameterizedType pt = findCallbackType(cls);
        Type actualType = null;
        for (Type tp : pt.getActualTypeArguments()) {
            actualType = tp;
            break;
        }
        if (actualType instanceof TypeVariable) {
            actualType = InjectionUtils.getSuperType(cls, (TypeVariable<?>)actualType);
        }
        return actualType;
    }

    protected static ParameterizedType findCallbackType(Class<?> cls) {
        if (cls == null || cls == Object.class) {
            return null;
        }
        for (Type c2 : cls.getGenericInterfaces()) {
            if (c2 instanceof ParameterizedType) {
                ParameterizedType pt = (ParameterizedType)c2;
                if (InvocationCallback.class.equals(pt.getRawType())) {
                    return pt;
                }
            }
        }
        return findCallbackType(cls.getSuperclass());
    }

    protected static Class<?> getCallbackClass(Type outType) {
        Class<?> respClass = null;
        if (outType instanceof Class) {
            respClass = (Class<?>)outType;
        } else if (outType instanceof ParameterizedType) {
            ParameterizedType pt = (ParameterizedType)outType;
            if (pt.getRawType() instanceof Class) {
                respClass = (Class<?>)pt.getRawType();
            }
        } else if (outType == null) {
            respClass = Response.class;
        }
        return respClass;
    }

    protected void resetResponseStateImmediatelyIfNeeded() {
        if (state instanceof ThreadLocalClientState
            && cfg.isResetThreadLocalStateImmediately()) {
            state.reset();
        }
    }

    protected abstract static class AbstractBodyWriter extends AbstractOutDatabindingInterceptor {

        public AbstractBodyWriter() {
            super(Phase.WRITE);
        }

        @Override
        public void handleMessage(Message outMessage) throws Fault {
            MessageContentsList objs = MessageContentsList.getContentsList(outMessage);
            if (objs == null || objs.isEmpty()) {
                return;
            }

            OutputStream os = outMessage.getContent(OutputStream.class);
            if (os == null) {
                XMLStreamWriter writer = outMessage.getContent(XMLStreamWriter.class);
                if (writer == null) {
                    return;
                }
            }

            Object body = objs.get(0);
            Annotation[] customAnns = (Annotation[])outMessage.get(Annotation.class.getName());
            Type t = outMessage.get(Type.class);
            doWriteBody(outMessage, body, t, customAnns, os);
        }

        protected abstract void doWriteBody(Message outMessage,
                                            Object body,
                                            Type bodyType,
                                            Annotation[] customAnns,
                                            OutputStream os) throws Fault;
    }

    private final class RetryableImpl implements Retryable {

        @Override
        public Object[] invoke(BindingOperationInfo oi, Object[] params, Map<String, Object> context,
                               Exchange exchange) throws Exception {
            return AbstractClient.this.retryInvoke(oi, params, context, exchange);
        }

    }
    private static class ConnectionFaultInterceptor extends AbstractPhaseInterceptor<Message> {
        ConnectionFaultInterceptor() {
            super(Phase.PRE_STREAM);
        }

        @Override
        public void handleMessage(Message message) throws Fault {
            if (!message.getExchange().isSynchronous()) {
                Throwable ex = message.getContent(Exception.class);
                if (ex == null) {
                    ex = message.getExchange().get(Exception.class);
                }
                if (ex != null) {
                    JaxrsClientCallback<?> cb = message.getExchange().get(JaxrsClientCallback.class);
                    if (ex instanceof Fault) {
                        ex = ex.getCause();
                    }
                    ex = ex instanceof ProcessingException ? ex : new ProcessingException(ex);
                    cb.handleException(message, ex);
                }
            }
        }
    }

    protected abstract class AbstractClientAsyncResponseInterceptor extends AbstractPhaseInterceptor<Message> {
        AbstractClientAsyncResponseInterceptor() {
            super(Phase.UNMARSHAL);
        }

        @Override
        public void handleMessage(Message message) throws Fault {
            synchronized (message.getExchange()) {
                message.getExchange().put("IN_CHAIN_COMPLETE", Boolean.TRUE);
                message.getExchange().notifyAll();
            }
            if (message.getExchange().isSynchronous()) {
                return;
            }
            handleAsyncResponse(message);
        }

        @Override
        public void handleFault(Message message) {
            synchronized (message.getExchange()) {
                message.getExchange().put("IN_CHAIN_COMPLETE", Boolean.TRUE);
                message.getExchange().notifyAll();
            }
            if (message.getExchange().isSynchronous()) {
                return;
            }
            handleAsyncFault(message);
        }

        private void handleAsyncResponse(Message message) {
            JaxrsClientCallback<?> cb = message.getExchange().get(JaxrsClientCallback.class);
            Response r = null;
            try {
                Object[] results = preProcessResult(message);
                if (results != null && results.length == 1) {
                    r = (Response)results[0];
                }
            } catch (WebApplicationException | ProcessingException ex) {
                cb.handleException(message, ex);
                return;
            } catch (Exception ex) {
                cb.handleException(message, new ProcessingException(ex));
                return;
            }
            doHandleAsyncResponse(message, r, cb);
        }

        protected abstract void doHandleAsyncResponse(Message message, Response r, JaxrsClientCallback<?> cb);

        protected void closeAsyncResponseIfPossible(Response r, Message outMessage, JaxrsClientCallback<?> cb) {
            if (responseStreamCanBeClosed(outMessage, cb.getResponseClass())) {
                r.close();
            }
        }

        protected void handleAsyncFault(Message message) {
        }
    }
    
    /**
     * References the configuration (by another client) so it won't shut down till all the 
     * client instances are closed. 
     * @param reference configuration reference
     */
    protected void setConfigurationReference(ConfigurationReference reference) {
        if (reference == null) {
            throw new IllegalArgumentException("The configuration reference is not set "
                + "(the client was already closed)");
        }
        this.configurationReference = reference.acquire();
        this.cfg = configurationReference.getConfiguration();
    }

    /**
     * Returns configuration reference
     * @return configuration reference
     */
    protected @Nullable ConfigurationReference getConfigurationReference() {
        return this.configurationReference;
    }
}
