/*
 * 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 org.apache.bookkeeper.metadata.etcd.helpers;

import com.google.common.annotations.VisibleForTesting;
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.common.exception.ClosedClientException;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.options.WatchOption;
import io.etcd.jetcd.watch.WatchResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.apache.bookkeeper.common.concurrent.FutureUtils;
import org.apache.bookkeeper.metadata.etcd.EtcdWatchClient;
import org.apache.bookkeeper.metadata.etcd.EtcdWatcher;
import org.apache.bookkeeper.versioning.LongVersion;
import org.apache.bookkeeper.versioning.Versioned;

/**
 * A helper class to read the stream of values of a given key.
 */
@Slf4j
public class ValueStream<T> implements BiConsumer<WatchResponse, Throwable>, AutoCloseable {

    private final Client client;
    private final boolean ownWatchClient;
    private final EtcdWatchClient watchClient;
    private final Function<ByteSequence, T> encoder;
    private final ByteSequence key;
    private final Map<Consumer<Versioned<T>>, RevisionedConsumer<T>> consumers =
        new HashMap<>();
    private volatile T localValue = null;
    private volatile long revision = -1L;
    private CompletableFuture<EtcdWatcher> watchFuture = null;
    private CompletableFuture<Void> closeFuture = null;

    public ValueStream(Client client,
                       Function<ByteSequence, T> encoder,
                       ByteSequence key) {
        this(client, new EtcdWatchClient(client), encoder, key);
    }

    public ValueStream(Client client,
                       EtcdWatchClient watchClient,
                       Function<ByteSequence, T> encoder,
                       ByteSequence key) {
        this.client = client;
        this.watchClient = watchClient;
        this.ownWatchClient = false;
        this.encoder = encoder;
        this.key = key;
    }

    public CompletableFuture<Versioned<T>> read() {
        return client.getKVClient().get(
            key
        ).thenApply(getResp -> {
            boolean updated = updateLocalValue(getResp);
            Versioned<T> localValue = getLocalValue();
            try {
                return localValue;
            } finally {
                if (updated) {
                    notifyConsumers(localValue);
                }
            }
        });
    }

    @VisibleForTesting
    public int getNumConsumers() {
        synchronized (consumers) {
            return consumers.size();
        }
    }

    private void notifyConsumers(Versioned<T> localValue) {
        synchronized (consumers) {
            consumers.values().forEach(consumer -> consumer.accept(localValue));
        }
    }

    private synchronized boolean updateLocalValue(GetResponse response) {
        if (revision < response.getHeader().getRevision()) {
            revision = response.getHeader().getRevision();
            if (response.getCount() > 0) {
                localValue = encoder.apply(response.getKvs().get(0).getValue());
            } else {
                localValue = null;
            }
            return true;
        } else {
            return false;
        }
    }

    private synchronized Versioned<T> processWatchResponse(WatchResponse response) {
        if (null != closeFuture) {
            return null;
        }
        if (log.isDebugEnabled()) {
            log.debug("Received watch response : revision = {}, {} events = {}",
                response.getHeader().getRevision(), response.getEvents().size(), response.getEvents());
        }

        if (response.getHeader().getRevision() <= revision) {
            return null;
        }
        revision = response.getHeader().getRevision();
        response.getEvents().forEach(event -> {
            switch (event.getEventType()) {
                case PUT:
                    this.localValue = encoder.apply(event.getKeyValue().getValue());
                    break;
                case DELETE:
                    this.localValue = null;
                    break;
                default:
                    // ignore
                    break;
            }

        });
        return getLocalValue();
    }

    @VisibleForTesting
    synchronized Versioned<T> getLocalValue() {
        return new Versioned<>(
            localValue,
            new LongVersion(revision)
        );
    }

    private CompletableFuture<Versioned<T>> getOrRead() {
        boolean shouldRead = false;
        synchronized (this) {
            if (revision < 0L) {
                // the value is never cached.
                shouldRead = true;
            }
        }
        if (shouldRead) {
            return read();
        } else {
            return FutureUtils.value(getLocalValue());
        }
    }

    @VisibleForTesting
    synchronized boolean isWatcherSet() {
        return null != watchFuture;
    }

    private synchronized CompletableFuture<EtcdWatcher> getWatchFuture() {
        return this.watchFuture;
    }

    @VisibleForTesting
    public CompletableFuture<EtcdWatcher> waitUntilWatched() {
        CompletableFuture<EtcdWatcher> wf;
        while ((wf = getWatchFuture()) == null) {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                if (log.isDebugEnabled()) {
                    log.debug("Interrupted at waiting until the key is watched", e);
                }
            }
        }
        return wf;
    }

    public CompletableFuture<Versioned<T>> readAndWatch(Consumer<Versioned<T>> consumer) {
        final RevisionedConsumer<T> revisionedConsumer = new RevisionedConsumer<>(consumer);
        final boolean consumerExisted;
        synchronized (consumers) {
            consumerExisted = (null != consumers.put(consumer, revisionedConsumer));
        }
        if (consumerExisted) {
            return getOrRead();
        }

        return getOrRead()
            .thenCompose(versionedVal -> {
                long revision = ((LongVersion) versionedVal.getVersion()).getLongVersion();
                synchronized (this) {
                    notifyConsumers(versionedVal);
                }
                return watchIfNeeded(revision).thenApply(ignored -> versionedVal);
            });
    }

    public CompletableFuture<Boolean> unwatch(Consumer<Versioned<T>> consumer) {
        boolean lastConsumer;
        synchronized (consumers) {
            lastConsumer = (null != consumers.remove(consumer) && consumers.isEmpty());
        }
        if (lastConsumer) {
            return closeOrRewatch(false).thenApply(ignored -> true);
        } else {
            return FutureUtils.value(false);
        }
    }

    private synchronized CompletableFuture<EtcdWatcher> watchIfNeeded(long revision) {
        if (null != watchFuture) {
            return watchFuture;
        }
        watchFuture = watch(revision);
        return watchFuture;
    }

    private CompletableFuture<EtcdWatcher> watch(long revision) {
        WatchOption.Builder optionBuilder = WatchOption.newBuilder()
            .withRevision(revision);
        return watchClient.watch(key, optionBuilder.build(), this)
            .whenComplete((watcher, cause) -> {
                if (null != cause) {
                    synchronized (ValueStream.this) {
                        watchFuture = null;
                    }
                }
            });
    }

    private CompletableFuture<Void> closeOrRewatch(boolean rewatch) {
        CompletableFuture<EtcdWatcher> oldWatcherFuture;
        synchronized (this) {
            oldWatcherFuture = watchFuture;
            if (rewatch && null == closeFuture) {
                watchFuture = watch(revision);
            } else {
                watchFuture = null;
            }
        }
        if (null != oldWatcherFuture) {
            return oldWatcherFuture.thenCompose(EtcdWatcher::closeAsync);
        } else {
            return FutureUtils.Void();
        }
    }

    @Override
    public void accept(WatchResponse watchResponse, Throwable throwable) {
        if (null == throwable) {
            if (log.isDebugEnabled()) {
                log.debug("Received watch response : revision = {}, {} events = {}",
                    watchResponse.getHeader().getRevision(),
                    watchResponse.getEvents().size(),
                    watchResponse.getEvents());
            }

            synchronized (this) {
                Versioned<T> localValue = processWatchResponse(watchResponse);
                if (null != localValue) {
                    notifyConsumers(localValue);
                }
            }
        } else {
            // rewatch if it is not a `ClosedClientException`
            closeOrRewatch(!(throwable instanceof ClosedClientException));
        }
    }

    public CompletableFuture<Void> closeAsync() {
        CompletableFuture<Void> future;
        synchronized (this) {
            if (null == closeFuture) {
                closeFuture = closeOrRewatch(false).thenCompose(ignored -> {
                    if (ownWatchClient) {
                        return watchClient.closeAsync();
                    } else {
                        return FutureUtils.Void();
                    }
                });
            }
            future = closeFuture;
        }
        return future;
    }

    @Override
    public void close() {
        try {
            FutureUtils.result(closeAsync());
        } catch (Exception e) {
            log.warn("Encountered exceptions on closing key reader : {}", e.getMessage());
        }
    }
}
