/*
 * Copyright 2017, 2018 IBM Corp. All Rights Reserved.
 *
 * Licensed 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 com.ibm.etcd.client;

import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static java.util.concurrent.TimeUnit.MILLISECONDS;

import java.lang.reflect.Proxy;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.LockSupport;
import java.util.function.Function;
import java.util.function.Supplier;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Throwables;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningScheduledExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.RateLimiter;
import com.google.common.util.concurrent.SettableFuture;
import com.google.common.util.concurrent.Uninterruptibles;

import io.grpc.CallCredentials;
import io.grpc.CallOptions;
import io.grpc.Deadline;
import io.grpc.ManagedChannel;
import io.grpc.MethodDescriptor;
import io.grpc.Status;
import io.grpc.Status.Code;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import io.netty.util.concurrent.OrderedEventExecutor;


/**
 * grpc client support - non-etcd specific
 * 
 */
public class GrpcClient {

    private static final Logger logger = LoggerFactory.getLogger(GrpcClient.class);

    private final long defaultTimeoutMs;
 
    public interface AuthProvider {
        /**
         * Called from a synchronized context
         */
        CallCredentials refreshCredentials();
        default CallCredentials refreshCredentials(Throwable trigger) {
            return refreshCredentials();
        }
        /**
         * Not called from a synchronized context
         */
        boolean requiresReauth(Throwable t);
    }

    private static final AuthProvider NO_AUTH = new AuthProvider() {
        @Override public boolean requiresReauth(Throwable t) { return false; }
        @Override public CallCredentials refreshCredentials() { throw new IllegalStateException(); }
    };

    private final AuthProvider authProvider;

    private final ManagedChannel channel;

    protected final ListeningScheduledExecutorService ses;
    protected final Executor userExecutor;
    // returns true if the current thread belongs to the internal executor
    protected final Condition isEventThread;

    // if true (default) all calls to etcd will be made via the shared
    // internal executor (grpc netty eventloopgroup).
    // this ensures better concentration of ThreadLocal bytebuf caches
    // which are allocated on this path
    protected final boolean sendViaEventLoop;

    // limit overall rate of immediate retries after failed calls to 1/sec,
    // to avoid excessive requests when there are connection problems
    protected final RateLimiter immediateRetryLimiter = RateLimiter.create(1.0);

    // modified only by reauthenticate() method
    private CallOptions callOptions = CallOptions.DEFAULT; // volatile tbd - lazy probably ok

    /**
     * @deprecated use other constructor
     */
    @Deprecated
    public GrpcClient(ManagedChannel channel,
            Predicate<Throwable> reauthRequired,
            Supplier<CallCredentials> credsSupplier,
            ScheduledExecutorService executor, Condition isEventThread, 
            Executor userExecutor, boolean sendViaEventLoop, long defaultTimeoutMs) {
        this(channel, reauthRequired == null ? null : new AuthProvider() {
            {
                Preconditions.checkArgument((reauthRequired == null) == (credsSupplier == null),
                        "must supply both or neither reauth and creds");
            }
            @Override
            public boolean requiresReauth(Throwable t) {
                return reauthRequired.apply(t);
            }
            @Override
            public CallCredentials refreshCredentials() {
                return credsSupplier.get();
            }
        }, executor, isEventThread, userExecutor, sendViaEventLoop, defaultTimeoutMs);
    }

    //TODO whether some/all of the channel construction is moved in here
    public GrpcClient(ManagedChannel channel, AuthProvider authProvider,
            ScheduledExecutorService executor, Condition isEventThread, 
            Executor userExecutor, boolean sendViaEventLoop, long defaultTimeoutMs) {
        this.channel = Preconditions.checkNotNull(channel, "channel");
        this.authProvider = authProvider != null ? authProvider : NO_AUTH;
        this.ses = MoreExecutors.listeningDecorator(executor);
        this.isEventThread = Preconditions.checkNotNull(isEventThread, "isEventThread");
        this.userExecutor = Preconditions.checkNotNull(userExecutor, "userExecutor");
        this.sendViaEventLoop = sendViaEventLoop;
        this.defaultTimeoutMs = defaultTimeoutMs;
    }

    /**
     * @deprecated use {@link #getInternalExecutor()}
     */
    @Deprecated
    public ScheduledExecutorService getExecutor() {
        return ses;
    }

    /**
     * Care should be taken not to use this executor for any blocking
     * or CPU intensive tasks.
     */
    public ScheduledExecutorService getInternalExecutor() {
        return ses;
    }

    public Executor getResponseExecutor() {
        return userExecutor;
    }

    public void authenticateNow() {
        assert authProvider != NO_AUTH;
        reauthenticate(getCallOptions(), null);
    }

    protected CallOptions getCallOptions() {
        return callOptions;
    }

    static final RetryDecision<?> IDEMP = (t,r) -> {
        Status status = Status.fromThrowable(t);
        Code code = status != null ? status.getCode() : null;
        return code == Code.UNAVAILABLE
                || code == Code.DEADLINE_EXCEEDED
                || (code == Code.UNKNOWN
                && status.getDescription() != null
                && status.getDescription().startsWith("Channel closed"));
    };

    static final RetryDecision<?> NON_IDEMP = (t,r) -> {
        Status status = Status.fromThrowable(t);
        Code code = status != null ? status.getCode() : null;
        return (code == Code.UNAVAILABLE && isConnectException(t))
                || (code == Code.UNKNOWN && status.getDescription() != null
                // This *should* have RESOURCE_EXHAUSTED code, but it doesn't
                // seem to come through that way, at least on etcd versions up to 3.3.17
                && status.getDescription().contains("etcdserver: too many requests"));
    };

    @SuppressWarnings("unchecked")
    public static <R> RetryDecision<R> retryDecision(boolean idempotent) {
        return (RetryDecision<R>) (idempotent ? IDEMP : NON_IDEMP);
    }

    public <ReqT,R> ListenableFuture<R> call(MethodDescriptor<ReqT,R> method,
            ReqT request, boolean idempotent) {
        return call(method, null, request, null, retryDecision(idempotent),
                0, false, false, null, 0L);
    }

    public <ReqT,R> ListenableFuture<R> call(MethodDescriptor<ReqT,R> method,
            ReqT request, boolean idempotent, long timeoutMillis, Executor executor) {
        return call(method, null, request, executor, retryDecision(idempotent),
                0, false, false, null, timeoutMillis);
    }

    //TODO probably move this
    public static interface RetryDecision<ReqT> {
        boolean retry(Throwable t, ReqT request);
    }

    public <ReqT,R> ListenableFuture<R> call(MethodDescriptor<ReqT,R> method, Condition precondition,
            ReqT request, Executor executor, RetryDecision<ReqT> retry, boolean backoff,
            Deadline deadline, long timeoutMs) {
        return call(method, precondition, request, executor, retry, 0, false, backoff, deadline, timeoutMs);
    }

    // deadline is for entire request (including retry pauses),
    // timeout is per-attempt and 0 means not specified
    private <ReqT,R> ListenableFuture<R> call(MethodDescriptor<ReqT,R> method,
            Condition precondition, ReqT request, Executor executor, RetryDecision<ReqT> retry,
            int attempt, boolean afterReauth, boolean backoff, Deadline deadline, long timeoutMs) {

        if (precondition != null && !precondition.satisfied()) {
            return failInExecutor(new CancellationException("precondition false"), executor);
        }
        //TODO(maybe) in delay case (attempt > 1), if "session" is inactive,
        //     skip attempt (and don't increment attempt #)
        final CallOptions baseCallOpts = getCallOptions();
        CallOptions callOpts = deadline != null ? baseCallOpts.withDeadline(deadline) : baseCallOpts;
        if (executor != null) {
            callOpts = callOpts.withExecutor(executor);
        }
        return Futures.catchingAsync(fuCall(method, request, callOpts, timeoutMs), Exception.class, t -> {
            // first cases: determine if we fail immediately
            if ((!backoff && attempt > 0) || (deadline != null && deadline.isExpired())) {
                // multiple retries disabled or deadline expired
                return Futures.immediateFailedFuture(t);
            }
            boolean reauth = false;
            if (authProvider.requiresReauth(t)) {
                if (afterReauth) {
                    // if we have an auth failure immediately following a reauth, give up
                    // (important to avoid infinite loop of auth failures)
                    return Futures.immediateFailedFuture(t);
                }
                reauthenticate(baseCallOpts, t);
                reauth = true;
            } else if (!retry.retry(t, request)) {
                // retry predicate says no (non retryable request and/or error)
                return Futures.immediateFailedFuture(t);
            }

            // second case: immediate retry (first failure or after auth failure + reauth)
            if (reauth || attempt == 0 && immediateRetryLimiter.tryAcquire()) {
                return call(method, precondition, request, executor, retry,
                        reauth ? attempt : 1, reauth, backoff, deadline, timeoutMs);
            }
            int nextAttempt = attempt <= 1 ? 2 : attempt + 1; // skip attempt if we were rate-limited

            // final case: retry after back-off delay
            long delayMs = delayAfterFailureMs(nextAttempt);
            if (deadline != null && deadline.timeRemaining(MILLISECONDS) < delayMs) {
                return Futures.immediateFailedFuture(t);
            }
            return Futures.scheduleAsync(() -> call(method, precondition, request, executor, retry,
                    nextAttempt, false, backoff, deadline, timeoutMs), delayMs, MILLISECONDS, ses);
        }, executor != null ? executor : directExecutor());
    }

    /**
     * @param failedAttemptNumber number of the attempt which just failed, 1-based 
     */
    static long delayAfterFailureMs(int failedAttemptNumber) {
        // backoff delay pattern: 0, [500ms - 1sec), 2sec, 4sec, 8sec, 8sec, ... (jitter after first retry)
        if (failedAttemptNumber <= 1) {
            return 0L;
        }
        return failedAttemptNumber == 2
                ? 500L + ThreadLocalRandom.current().nextLong(500L)
                : (2000L << Math.min(failedAttemptNumber - 3, 2));
    }

    protected static <T> ListenableFuture<T> failInExecutor(Throwable t, Executor executor) {
        if (executor == null) {
            return Futures.immediateFailedFuture(t);
        }
        SettableFuture<T> lfut = SettableFuture.create();
        executor.execute(() -> lfut.setException(t));
        return lfut;
    }

    //TODO(maybe) for retriable RPCs consider fail-fast first timeout and longer retry timeout
    protected <ReqT,R> ListenableFuture<R> fuCall(MethodDescriptor<ReqT,R> method, ReqT request,
            CallOptions callOptions, long timeoutMs) {
        if (timeoutMs <= 0L) {
            timeoutMs = defaultTimeoutMs;
        }
        if (timeoutMs > 0L) {
            Deadline deadline = callOptions.getDeadline();
            Deadline timeoutDeadline = Deadline.after(timeoutMs, MILLISECONDS);
            if (deadline == null || timeoutDeadline.isBefore(deadline)) {
                callOptions = callOptions.withDeadline(timeoutDeadline);
            } else if (deadline.isExpired()) {
                return Futures.immediateFailedFuture(
                        Status.DEADLINE_EXCEEDED.asRuntimeException());
            }
        }
        final CallOptions finalCallOpts = callOptions;
        return sendViaEventLoop && !isEventThread.satisfied()
                ? Futures.submitAsync(() -> fuCall(method, request, finalCallOpts), ses)
                        : fuCall(method, request, finalCallOpts);
    }

    protected <ReqT,R> ListenableFuture<R> fuCall(MethodDescriptor<ReqT,R> method, ReqT request,
            CallOptions callOptions) {
        return ClientCalls.futureUnaryCall(channel.newCall(method, callOptions), request);
    }

    protected boolean retryableStreamError(Throwable error) {
        return (Status.fromThrowable(error).getCode() != Code.INVALID_ARGUMENT
                && !causedBy(error, Error.class));
    }

    /**
     * @return true if reauthentication was required and attempted
     */
    protected boolean reauthIfRequired(Throwable error, CallOptions callOpts) {
        if (authProvider.requiresReauth(error)) {
            reauthenticate(callOpts, error);
            return true;
        }
        return false;
    }

    public static boolean isConnectException(Throwable t) {
        return causedBy(t, ConnectException.class) || causedBy(t, NoRouteToHostException.class);
    }

    public static Code codeFromThrowable(Throwable t) {
        return Status.fromThrowable(t).getCode(); // fromThrowable won't return null
    }


    private void reauthenticate(CallOptions failedOpts, Throwable authFailure) {
        // assert name != null && password != null;
        if (getCallOptions() == failedOpts) { // obj identity comparison intentional
            synchronized (this) {
                CallOptions callOpts = getCallOptions();
                if (callOpts == failedOpts) {
                    callOptions = callOpts.withCallCredentials(
                            authProvider.refreshCredentials(authFailure));
                }
            }
        }
    }

    public <ReqT,RespT> StreamObserver<ReqT> callStream(MethodDescriptor<ReqT,RespT> method,
            ResilientResponseObserver<ReqT,RespT> respStream) {
        return callStream(method, respStream, null);
    }

    public <ReqT,RespT> StreamObserver<ReqT> callStream(MethodDescriptor<ReqT,RespT> method,
            ResilientResponseObserver<ReqT,RespT> respStream, Executor responseExecutor) {
        // must explicitly auth in stream case to ensure unauthenticated version isn't used
        if (authProvider != NO_AUTH && getCallOptions() == CallOptions.DEFAULT) {
            // This will update callOptions with new CallCredentials prior to opening the stream
            authenticateNow();
        }
        return new ResilientBiDiStream<>(method, respStream, responseExecutor).start();
    }

    public static interface ResilientResponseObserver<ReqT,RespT> extends StreamObserver<RespT> {
        /**
         * Called once initially, and once after each {@link #onReplaced(StreamObserver)},
         * to indicate the corresponding (sub) stream is successfully established
         */
        public void onEstablished();

        /**
         * Indicates the underlying stream failed and will be re-established. There is
         * no guarantee that any requests sent to the current request stream have
         * been delivered, the provided stream should be used in its place to send
         * all subsequent requests, including re-submissions if necessary. Any subsequent
         * {@link #onEstablished()} or {@link #onNext(Object)} calls received will
         * be responses from this <b>new</b> stream, it's guaranteed that there will
         * be no more from the prior stream.
         * 
         * @param newStreamRequestObserver
         */
        public void onReplaced(StreamObserver<ReqT> newStreamRequestObserver);
    }


    final class ResilientBiDiStream<ReqT,RespT> {
        private final MethodDescriptor<ReqT,RespT> method;
        private final ResilientResponseObserver<ReqT,RespT> respStream;
        private final Executor responseExecutor;

        // null if !sendViaEventLoop
        private final Executor requestExecutor;

        // accessed only from response thread and retry task scheduled
        // from the onError message (prior to stream being reestablished)
        private CallOptions sentCallOptions;
        private int errCounter = 0;

        // provided to user, buffers and wraps real req stream when active.
        // field accessed only from response thread
        private RequestSubStream userReqStream;

        // finished reflects *user* closing stream via terminal method (not incoming stream closure)
        // error == null indicates complete versus failed when finished == true
        // modified only by response thread
        private boolean finished;
        private Throwable error;

        private boolean lastAuthFailed;

        /**
         * 
         * @param method
         * @param respStream
         * @param responseExecutor
         */
        public ResilientBiDiStream(MethodDescriptor<ReqT,RespT> method,
                ResilientResponseObserver<ReqT,RespT> respStream,
                Executor responseExecutor) {
            this.method = method;
            this.respStream = respStream;
            this.responseExecutor = serialized(responseExecutor != null
                    ? responseExecutor : userExecutor);
            this.requestExecutor = sendViaEventLoop ? serialized(ses) : null;
        }

        // must only be called once - enforcement logic omitted since private
        StreamObserver<ReqT> start() {
            RequestSubStream firstStream = new RequestSubStream();
            userReqStream = firstStream;
            responseExecutor.execute(this::refreshBackingStream);
            return firstStream;
        }

        class RequestSubStream implements StreamObserver<ReqT> {
            // lifecycle: null -> real stream -> EMPTY_STREAM
            private volatile StreamObserver<ReqT> grpcReqStream; // only modified by response thread
            // grpcReqStream non-null => preConnectBuffer null
            private Queue<ReqT> preConnectBuffer;

            // called by user thread
            @Override
            public void onNext(ReqT value) {
                if (finished) {
                    return; // illegal usage
                }
                StreamObserver<ReqT> rs = grpcReqStream;
                if (rs == null) synchronized (this) {
                    rs = grpcReqStream;
                    if (rs == null) {
                        if (preConnectBuffer == null) {
                            preConnectBuffer = new ArrayDeque<>(8); // bounded TBD
                        }
                        preConnectBuffer.add(value);
                        return;
                    }
                }

                if (requestExecutor == null) {
                    sendOnNext(rs, value); // (***)
                } else {
                    final StreamObserver<ReqT> rsFinal = rs;
                    requestExecutor.execute(() -> sendOnNext(rsFinal, value));
                }
            }

            private void sendOnNext(StreamObserver<ReqT> reqStream, ReqT value) {
                try {
                    reqStream.onNext(value);
                } catch (IllegalStateException ise) {
                    // this is possible and ok if the stream was already closed
                    if (grpcReqStream != emptyStream()) throw ise;
                }
            }

            // called by user thread
            @Override
            public void onError(Throwable t) {
                onFinish(t);
            }
            // called by user thread
            @Override
            public void onCompleted() {
                onFinish(null);
            }

            // called from response thread
            boolean established(StreamObserver<ReqT> stream) {
                StreamObserver<ReqT> curStream = grpcReqStream;
                if (curStream == null) synchronized (this) {
                    Queue<ReqT> pcb = preConnectBuffer;
                    if (pcb != null) {
                        for (ReqT req; (req = pcb.poll()) != null;) {
                            stream.onNext(req);
                        }
                        preConnectBuffer = null;
                    }
                    initialReqStream = null;
                    if (finished) {
                        grpcReqStream = emptyStream();
                    } else {
                        grpcReqStream = stream;
                        return true;
                    }
                } else if (stream == curStream) {
                    return false;
                }

                // here either finished or it's an unexpected new stream
                if (!finished) {
                    logger.info("Closing unexpected new stream of method "
                            + method.getFullMethodName());
                }
                closeStream(stream, error);
                return false;
            }

            boolean isEstablished() {
                return grpcReqStream != null;
            }

            // called from grpc response thread
            void discard(Throwable err) {
                StreamObserver<ReqT> curStream = grpcReqStream, empty = emptyStream();
                if (curStream == empty) {
                    return;
                }
                if (curStream == null) synchronized (this) {
                    grpcReqStream = empty;
                    preConnectBuffer = null;
                } else {
                    //TODO this *could* overlap with an in-progress
                    //   onNext (***) above in the sendViaEventLoop == false case, but unlikely
                    // For now, delay sending the close to further minimize the chance
                    close(err, false);
                }
            }

            // called from grpc response thread
            void close(Throwable err, boolean fromUser) {
                StreamObserver<ReqT> curStream = grpcReqStream, empty = emptyStream();
                if (curStream == null || curStream == empty) {
                    return;
                }
                grpcReqStream = empty;
                //assert preConnectBuffer == null;
                if (fromUser) {
                    closeStream(curStream, err);
                } else {
                    Runnable closeTask = () -> closeStream(curStream, err);
                    if (requestExecutor != null) {
                        requestExecutor.execute(closeTask);
                    } else {
                        ses.schedule(closeTask, 400, MILLISECONDS);
                    }
                }
            }
        }

        // called by user thread
        private void onFinish(Throwable err) {
            if (finished) {
                return; // shouldn't be called more than once anyhow
            }
            responseExecutor.execute(() -> {
                if(finished) {
                    return;
                }

                // Don't treat this as final if authentication
                // is enabled and the error reflects that reauth
                // is required - instead just cancel the "current"
                // stream which will cause the top-level stream
                // to be refreshed after a reauth is done
                if (err == null || !authProvider.requiresReauth(err)) {
                    error = err;
                    finished = true;
                }
                userReqStream.close(err, true);
            });
        }

        /*
         * We assume the caller (grpc) abides by StreamObserver contract
         */
        private final StreamObserver<RespT> respWrapper = new ClientResponseObserver<ReqT,RespT>() {
            @Override
            public void beforeStart(ClientCallStreamObserver<ReqT> rs) {
                rs.setOnReadyHandler(() -> {
                    // called from grpc response thread
                    if (rs.isReady()) {
                        errCounter = 0;
                        boolean notify = userReqStream.established(rs);
                        if (notify) {
                            respStream.onEstablished();
                        }
                    }
                });
            }
            // called from grpc response thread
            @Override
            public void onNext(RespT value) {
                lastAuthFailed = false;
                respStream.onNext(value);
            }
            // called from grpc response thread
            @Override
            public void onError(Throwable t) {
                boolean finalError, reauthed = false;
                if (finished) {
                    finalError = true;
                } else {
                    reauthed = !lastAuthFailed && reauthIfRequired(t, sentCallOptions);
                    finalError = !reauthed && !retryableStreamError(t);
                }
                lastAuthFailed = reauthed;
                if (!finalError) {
                    int errCount = -1;
                    String msg;
                    if (reauthed) {
                        msg = "Reauthenticating after auth error (likely expiry) on underlying"
                                + " stream of method " + method.getFullMethodName();
                    } else {
                        errCount = ++errCounter;
                        msg = "Retryable onError #" + errCount
                                + " on underlying stream of method " + method.getFullMethodName();
                    }
                    if (logger.isDebugEnabled()) {
                        logger.info(msg, t);
                    } else {
                        if (reauthed) {
                            t = Throwables.getRootCause(t);
                        }
                        logger.info(msg + ": " + t.getClass().getName() + ": " + t.getMessage());
                    }

                    RequestSubStream userStreamBefore = userReqStream;
                    if (userStreamBefore.isEstablished()) {
                        userReqStream = new RequestSubStream();
                        userStreamBefore.discard(null);

                        // must call onReplaced prior to refreshing the stream, otherwise
                        // the response observer may be called with responses from the
                        // new stream prior to onReplaced returning
                        respStream.onReplaced(userReqStream);
                    } else if (initialReqStream != null) {
                        // else no need to replace user stream, but cancel outbound stream
                        initialReqStream.onError(t);
                        initialReqStream = null;
                    }

                    // re-attempt immediately after reauthentication
                    if (reauthed || (errCount <= 1 && immediateRetryLimiter.tryAcquire())) {
                        refreshBackingStream();
                    } else {
                        // delay stream retry using backoff/jitter strategy
                        ses.schedule(ResilientBiDiStream.this::refreshBackingStream,
                                // skip attempt in rate-limited case (errCount <=1)
                                delayAfterFailureMs(errCount <= 2 ? 2 : errCount), MILLISECONDS);
                    }
                } else {
                    sentCallOptions = null;
                    userReqStream.discard(t);
                    respStream.onError(t);
                }
            }
            // called from grpc response thread
            @Override
            public void onCompleted() {
                lastAuthFailed = false;
                if (!finished) {
                    logger.warn("Unexpected onCompleted received"
                            + " for stream of method " + method.getFullMethodName());
                    //TODO(maybe) reestablish stream in this case?
                }
                sentCallOptions = null;
                userReqStream.discard(null);
                respStream.onCompleted();
            }
        };

        // called only from:
        // - grpc response thread
        // - scheduled retry (no active stream)
        private void refreshBackingStream() {
            if (finished) {
                return;
            }
            CallOptions callOpts = getCallOptions();
            sentCallOptions = callOpts;
            callOpts = callOpts.withExecutor(responseExecutor);
            initialReqStream = ClientCalls.asyncBidiStreamingCall(
                    channel.newCall(method, callOpts), respWrapper);
        }

        // this is just stored to cancel if the call fails before
        // being established
        private StreamObserver<ReqT> initialReqStream;
    }


    // ------- utilities

    public final <T> T waitForCall(Function<Executor,Future<T>> asyncCall) {
        return waitFor(asyncCall, userExecutor);
    }

    public static <T> T waitFor(Future<T> fut) {
        return waitFor(fut, -1L);
    }

    public static <T> T waitFor(Future<T> fut, long timeoutMillis) {
        try {
            return timeoutMillis < 0L ? fut.get() : fut.get(timeoutMillis, MILLISECONDS);
        } catch (InterruptedException|CancellationException e) {
            fut.cancel(true);
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            throw Status.CANCELLED.withCause(e).asRuntimeException();
        } catch (ExecutionException ee) {
            throw Status.fromThrowable(ee.getCause()).asRuntimeException();
        } catch (TimeoutException te) {
            fut.cancel(true);
            throw Status.DEADLINE_EXCEEDED.withCause(te)
                .withDescription("local timeout of " + timeoutMillis + "ms exceeded")
                .asRuntimeException();
        } catch (RuntimeException rte) {
            fut.cancel(true);
            throw Status.fromThrowable(rte).asRuntimeException();
        }
    }

    public static <T> T waitFor(Function<Executor,Future<T>> asyncCall) {
        return waitFor(asyncCall, null);
    }

    public static <T> T waitFor(Function<Executor,Future<T>> asyncCall,
            Executor fallbackExecutor) {
        ThreadlessExecutor exec = new ThreadlessExecutor(fallbackExecutor);
        try {
            Future<T> fut = asyncCall.apply(exec);
            while (!fut.isDone()) try {
                exec.waitAndDrain();
            } catch (InterruptedException ie) {
                if (!fut.isDone()) try {
                    fut.cancel(true);
                    exec.waitAndDrain();
                } catch (InterruptedException ie2) { }
                Thread.currentThread().interrupt();
                throw Status.CANCELLED.withCause(ie).asRuntimeException();
            }
            try {
                return Uninterruptibles.getUninterruptibly(fut);
            } catch (CancellationException e) {
                throw Status.CANCELLED.withCause(e).asRuntimeException();
            } catch (ExecutionException ee) {
                throw Status.fromThrowable(ee.getCause()).asRuntimeException();
            } catch (RuntimeException rte) {
                fut.cancel(true);
                throw Status.fromThrowable(rte).asRuntimeException();
            }
        } finally {
            // This is necessary to ensure the call is closed and doesn't leak resources
            exec.shutdown();
        }
    }

    protected static void closeStream(StreamObserver<?> stream, Throwable err) {
        if (err == null) {
            stream.onCompleted();
        } else {
            stream.onError(err);
        }
    }

    @SuppressWarnings("rawtypes")
    private static final StreamObserver<?> EMPTY_STREAM = new StreamObserver() {
        @Override public void onCompleted() {}
        @Override public void onError(Throwable t) {}
        @Override public void onNext(Object value) {}
    };

    @SuppressWarnings("unchecked")
    protected static <ReqT> StreamObserver<ReqT> emptyStream() {
        return (StreamObserver<ReqT>) EMPTY_STREAM;
    }

    protected static <T> Predicate<T> constantPredicate(boolean val) {
        return val ? Predicates.alwaysTrue() : Predicates.alwaysFalse();
    }

    protected static boolean contains(String str, String subStr) {
        return str != null && str.contains(subStr);
    }

    public static boolean causedBy(Throwable t, Class<? extends Throwable> exClass) {
        return t != null && (exClass.isAssignableFrom(t.getClass())
                || causedBy(t.getCause(), exClass));
    }

    @SuppressWarnings("unchecked")
    public static <I> I sentinel(Class<I> intface) {
        return (I) Proxy.newProxyInstance(intface.getClassLoader(),
                new Class<?>[] { intface }, (p,m,a) -> {
                    switch (m.getName()) {
                        case "toString": return "SENTINEL";
                        case "hashCode": return System.identityHashCode(p);
                        case "equals":   return a[0] == p;
                        default: throw new IllegalStateException("attempt to invoke sentinel");
                    }
                });
    }

    public static Executor serialized(Executor parent) {
        return serialized(parent, 0);
    }

    private static final Class<? extends Executor> GSE_CLASS
        = MoreExecutors.newSequentialExecutor(directExecutor()).getClass();

    public static Executor serialized(Executor parent, int bufferSize) {
        return parent instanceof SerializingExecutor
                || parent instanceof io.grpc.internal.SerializingExecutor
                || parent instanceof OrderedEventExecutor
                || parent.getClass() == GSE_CLASS
                ? parent : new SerializingExecutor(parent, bufferSize);
    }

    /**
     * Equivalent to the executor used in grpc ClientCalls class for blocking calls.
     */
    @SuppressWarnings("serial")
    private static final class ThreadlessExecutor extends ConcurrentLinkedQueue<Runnable>
      implements Executor {
        private static final Logger logger = LoggerFactory.getLogger(ThreadlessExecutor.class);

        private static final Thread SHUTDOWN = new Thread(); // sentinel

        private final Executor fallbackExecutor;
        private volatile Thread waiter;

        ThreadlessExecutor(Executor fallbackExecutor) {
            this.fallbackExecutor = fallbackExecutor;
        }

        public void waitAndDrain() throws InterruptedException {
            final Thread currentThread = Thread.currentThread();
            throwIfInterrupted(currentThread);
            Runnable runnable = poll();
            if (runnable == null) {
                waiter = currentThread;
                try {
                    while ((runnable = poll()) == null) {
                        LockSupport.park(this);
                        throwIfInterrupted(currentThread);
                    }
                } finally {
                    waiter = null;
                }
            }
            do {
                runQuietly(runnable);
            } while ((runnable = poll()) != null);
        }

        /**
         * Called after final call to {@link #waitAndDrain()}, from same thread.
         */
        public void shutdown() {
            waiter = SHUTDOWN;
            // There should usually be nothing to run here
            for (Runnable runnable; (runnable = poll()) != null;) {
                runQuietly(runnable);
            }
        }

        private static void runQuietly(Runnable runnable) {
            try {
                runnable.run();
            } catch (Throwable t) {
                Throwables.throwIfInstanceOf(t, Error.class);
                logger.warn("Runnable threw exception", t);
            }
        }

        private static void throwIfInterrupted(Thread currentThread) throws InterruptedException {
            if (currentThread.isInterrupted()) {
                throw new InterruptedException();
            }
        }

        @Override
        public void execute(Runnable runnable) {
            add(runnable);
            Thread waiter = this.waiter;
            if (waiter != SHUTDOWN) {
                LockSupport.unpark(waiter); // no-op if null
            } else if (remove(runnable)) {
                // Make sure the runnable gets run one way or another,
                // to ensure that resources are closed (this is a grpc-java
                // race condition bug in versions 1.26.0 - 1.30.0).
                // Note the Runnable will itself always be a SeralizingExecutor,
                // so there's no need to consider additional synchronization here
                if (fallbackExecutor != null) {
                    fallbackExecutor.execute(runnable);
                } else {
                    runQuietly(runnable);
                }
            }
        }
    }
}