/*
 * 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.curator.framework.recipes.cache;

import static org.apache.curator.framework.recipes.cache.CuratorCacheListener.Type.NODE_CHANGED;
import static org.apache.curator.framework.recipes.cache.CuratorCacheListener.Type.NODE_CREATED;
import static org.apache.curator.framework.recipes.cache.CuratorCacheListener.Type.NODE_DELETED;
import static org.apache.zookeeper.KeeperException.Code.NONODE;
import static org.apache.zookeeper.KeeperException.Code.OK;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Stream;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.api.BackgroundCallback;
import org.apache.curator.framework.api.CuratorEvent;
import org.apache.curator.framework.listen.Listenable;
import org.apache.curator.framework.listen.StandardListenerManager;
import org.apache.curator.framework.recipes.watch.PersistentWatcher;
import org.apache.curator.utils.ThreadUtils;
import org.apache.curator.utils.ZKPaths;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class CuratorCacheImpl implements CuratorCache, CuratorCacheBridge {
    private final Logger log = LoggerFactory.getLogger(getClass());
    private final AtomicReference<State> state = new AtomicReference<>(State.LATENT);
    private final PersistentWatcher persistentWatcher;
    private final CuratorFramework client;
    private final CuratorCacheStorage storage;
    private final String path;
    private final boolean recursive;
    private final boolean compressedData;
    private final boolean clearOnClose;
    private final StandardListenerManager<CuratorCacheListener> listenerManager = StandardListenerManager.standard();
    private final Consumer<Exception> exceptionHandler;

    private final OutstandingOps outstandingOps =
            new OutstandingOps(() -> callListeners(CuratorCacheListener::initialized));

    private enum State {
        LATENT,
        STARTED,
        CLOSED
    }

    CuratorCacheImpl(
            CuratorFramework client,
            CuratorCacheStorage storage,
            String path,
            Options[] optionsArg,
            Consumer<Exception> exceptionHandler) {
        Set<Options> options = (optionsArg != null) ? Sets.newHashSet(optionsArg) : Collections.emptySet();
        this.client = client;
        this.storage = (storage != null) ? storage : CuratorCacheStorage.standard();
        this.path = path;
        recursive = !options.contains(Options.SINGLE_NODE_CACHE);
        compressedData = options.contains(Options.COMPRESSED_DATA);
        clearOnClose = !options.contains(Options.DO_NOT_CLEAR_ON_CLOSE);
        persistentWatcher = new PersistentWatcher(client, path, recursive);
        persistentWatcher.getListenable().addListener(this::processEvent);
        persistentWatcher.getResetListenable().addListener(this::rebuild);
        this.exceptionHandler = (exceptionHandler != null) ? exceptionHandler : e -> log.error("CuratorCache error", e);
    }

    @Override
    public void start() {
        Preconditions.checkState(state.compareAndSet(State.LATENT, State.STARTED), "Already started");
        persistentWatcher.start();
    }

    @Override
    public void close() {
        if (state.compareAndSet(State.STARTED, State.CLOSED)) {
            persistentWatcher.close();
            if (clearOnClose) {
                storage.clear();
            }
        }
    }

    @Override
    public boolean isCuratorCache() {
        return true;
    }

    @Override
    public Listenable<CuratorCacheListener> listenable() {
        return listenerManager;
    }

    @Override
    public Optional<ChildData> get(String path) {
        return storage.get(path);
    }

    @Override
    public int size() {
        return storage.size();
    }

    @Override
    public Stream<ChildData> stream() {
        return storage.stream();
    }

    @VisibleForTesting
    CuratorCacheStorage storage() {
        return storage;
    }

    private void rebuild() {
        if (state.get() != State.STARTED) {
            return;
        }

        // rebuild from the root first
        nodeChanged(path);

        // rebuild remaining nodes - note: this may cause some nodes to be queried twice
        // (though versions checks will minimize that). If someone can think of a better
        // way let us know
        storage.stream().map(ChildData::getPath).filter(p -> !p.equals(path)).forEach(this::nodeChanged);
    }

    private void processEvent(WatchedEvent event) {
        if (state.get() != State.STARTED) {
            return;
        }

        // NOTE: Persistent/Recursive watchers never trigger NodeChildrenChanged

        switch (event.getType()) {
            case NodeDataChanged:
            case NodeCreated: {
                nodeChanged(event.getPath());
                break;
            }

            case NodeDeleted: {
                removeStorage(event.getPath());
                break;
            }
        }
    }

    private void checkChildrenChanged(String fromPath, Stat oldStat, Stat newStat) {
        if ((state.get() != State.STARTED) || !recursive) {
            return;
        }

        if ((oldStat != null) && (oldStat.getCversion() == newStat.getCversion())) {
            return; // children haven't changed
        }

        try {
            BackgroundCallback callback = (__, event) -> {
                if (event.getResultCode() == OK.intValue()) {
                    event.getChildren().forEach(child -> nodeChanged(ZKPaths.makePath(fromPath, child)));
                } else if (event.getResultCode() == NONODE.intValue()) {
                    removeStorage(event.getPath());
                } else {
                    handleException(event);
                }
                outstandingOps.decrement();
            };

            outstandingOps.increment();
            client.getChildren().inBackground(callback).forPath(fromPath);
        } catch (Exception e) {
            handleException(e);
        }
    }

    private void nodeChanged(String fromPath) {
        if (state.get() != State.STARTED) {
            return;
        }

        try {
            BackgroundCallback callback = (__, event) -> {
                if (event.getResultCode() == OK.intValue()) {
                    Optional<ChildData> childData =
                            putStorage(new ChildData(event.getPath(), event.getStat(), event.getData()));
                    checkChildrenChanged(
                            event.getPath(), childData.map(ChildData::getStat).orElse(null), event.getStat());
                } else if (event.getResultCode() == NONODE.intValue()) {
                    removeStorage(event.getPath());
                } else {
                    handleException(event);
                }
                outstandingOps.decrement();
            };

            outstandingOps.increment();
            if (compressedData) {
                client.getData().decompressed().inBackground(callback).forPath(fromPath);
            } else {
                client.getData().inBackground(callback).forPath(fromPath);
            }
        } catch (Exception e) {
            handleException(e);
        }
    }

    private Optional<ChildData> putStorage(ChildData data) {
        Optional<ChildData> previousData = storage.put(data);
        if (previousData.isPresent()) {
            if (previousData.get().getStat().getVersion() != data.getStat().getVersion()) {
                callListeners(l -> l.event(NODE_CHANGED, previousData.get(), data));
            }
        } else {
            callListeners(l -> l.event(NODE_CREATED, null, data));
        }
        return previousData;
    }

    private void removeStorage(String path) {
        storage.remove(path).ifPresent(previousData -> callListeners(l -> l.event(NODE_DELETED, previousData, null)));
    }

    private void callListeners(Consumer<CuratorCacheListener> proc) {
        if (state.get() == State.STARTED) {
            client.runSafe(() -> listenerManager.forEach(proc));
        }
    }

    private void handleException(CuratorEvent event) {
        handleException(KeeperException.create(KeeperException.Code.get(event.getResultCode())));
    }

    private void handleException(Exception e) {
        ThreadUtils.checkInterrupted(e);
        exceptionHandler.accept(e);
    }
}
