package ru.yandex.qe.cache;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.rmi.NotBoundException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import javax.net.SocketFactory;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import net.sf.ehcache.CacheException;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Ehcache;
import net.sf.ehcache.distribution.CachePeer;
import net.sf.ehcache.distribution.RMICacheManagerPeerProvider;
import net.sf.ehcache.util.NamedThreadFactory;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.slf4j.LoggerFactory;

import ru.yandex.qe.http.handler.FluentResponseHandler;
import ru.yandex.qe.json.DefaultJsonMapper;

/**
 * @author lvovich
 */
public class QloudCacheManagerPeerProvider extends RMICacheManagerPeerProvider {

    private final static int UPDATE_INTERVAL_MILLIS = 5000;
    private final static int SHORT_DELAY = 100;

    private final Set<String> cacheNames;
    private final String myHostName;
    private final int port;
    private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, new NamedThreadFactory("peers-updater", true));

    private HttpClient httpClient = new ru.yandex.qe.http.HttpClientBuilder()
            .setConnectionPoolSize(1)
            .setConnectTimeout(100)
            .setSocketTimeout(1000)
            .build();


    public QloudCacheManagerPeerProvider(final CacheManager cacheManager, final Set<String> cacheNames, final String myHostName, final int port) {
        super(cacheManager);
        this.cacheNames = cacheNames;
        this.myHostName = myHostName;
        this.port = port;
    }

    // "//" + hostName + ":" + port + "/" + cacheName;
    private String rmiUrl(String host, String cacheName) {
        return "//" + host + ":" + port + "/" + cacheName;
    }

    @Override
    public void init() {
        LoggerFactory.getLogger(getClass()).info("Starting with cacheNames={}, port={}, myHostName={}", cacheNames, port, myHostName);
        scheduler.scheduleWithFixedDelay(this::updateRmiUrls, 0, 5, TimeUnit.SECONDS);
    }

    @Override
    public void dispose() throws CacheException {
        scheduler.shutdownNow();
    }

    private void updateRmiUrls() {
        Set<String> peers = null;
        try {
            peers = findPeers();
        } catch (IOException e) {
            LoggerFactory.getLogger(getClass()).error("Failed to find peers via qloud local api, not updating peers", e);
            // do not update rmiUrls
            return;
        }
        Set<String> newRmiUrls = new HashSet<>();
        for (String hostName: peers) {
            for (String cacheName: cacheNames) {
                newRmiUrls.add(rmiUrl(hostName, cacheName));
            }
        }
        synchronized (peerUrls) {
            Set knownPeerUrlKeys = peerUrls.keySet();
            Set<String> knownPeerUrls = new HashSet<>();
            for (Object key: knownPeerUrlKeys) {
                knownPeerUrls.add((String)key);
            }
            Set<String> urlsToRemove = new HashSet<>(knownPeerUrls);
            urlsToRemove.removeAll(newRmiUrls);
            Set<String> urlsToAdd = new HashSet<>(newRmiUrls);
            urlsToAdd.removeAll(knownPeerUrls);
            for (String added: urlsToAdd) {
                registerPeer(added);
            }
            for (String removed: urlsToRemove) {
                unregisterPeer(removed);
                LoggerFactory.getLogger(getClass()).info("Unregistered peer: " + removed);
            }
        }
    }

    private Set<String> findPeers() throws IOException {
        String uri = "http://localhost:1/metadata?format=json";
        String response = httpClient.execute(
                new HttpGet(uri),
                FluentResponseHandler.newHandler()
                        .onNon2xx().doThrowStatus()
                        .returnString()
        );
        Metadata metadata = new DefaultJsonMapper().readValue(response, Metadata.class);
        Set<String> peers = metadata.getInstances().stream().map(InstanceInfo::getFqdn)
                .filter(x -> !x.equals(myHostName))
                .collect(Collectors.toCollection(HashSet::new));
        for (Iterator<String> iterator = peers.iterator(); iterator.hasNext(); ) {
            String peer = iterator.next();
            InetSocketAddress address = new InetSocketAddress(peer, port);
            try (Socket socket = SocketFactory.getDefault().createSocket()) {
                try {
                    socket.connect(address, 100);
                } catch (IOException e) {
                    // peer is unreachable; will try later
                    iterator.remove();
                    LoggerFactory.getLogger(getClass()).debug("Unreachable peer: {}", peer);
                }
            }
        }
        return peers;
    }

    @Override
    public void registerPeer(final String rmiUrl) {
        try {
            CachePeer cachePeerEntry = (CachePeer) peerUrls.get(rmiUrl);
            if (cachePeerEntry == null) {
                //can take seconds if there is a problem
                CachePeer cachePeer = lookupRemoteCachePeer(rmiUrl);
                //synchronized due to peerUrls being a synchronizedMap
                peerUrls.put(rmiUrl, cachePeer);
                LoggerFactory.getLogger(getClass()).info("Registered peer: " + rmiUrl);
            }
        } catch (IOException|NotBoundException e) {
                LoggerFactory.getLogger(getClass()).debug("Unable to lookup remote cache peer for " + rmiUrl + ". Removing from peer list. Cause was: "
                        + e);
            unregisterPeer(rmiUrl);
        } catch (Throwable t) {
            LoggerFactory.getLogger(getClass()).error("Unable to lookup remote cache peer for " + rmiUrl
                    + ". Cause was not due to an IOException or NotBoundException which will occur in normal operation:" +
                    " " + t.getMessage());
        }
    }

    /**
     * Gets the cache name out of the url
     * @param rmiUrl
     * @return the cache name as it would appear in ehcache.xml
     */
    private static String extractCacheName(String rmiUrl) {
        return rmiUrl.substring(rmiUrl.lastIndexOf('/') + 1);
    }


    @Override
    public List listRemoteCachePeers(final Ehcache cache) throws CacheException {
        List remoteCachePeers = new ArrayList();
        synchronized (peerUrls) {
            for (Iterator iterator = peerUrls.keySet().iterator(); iterator.hasNext();) {
                String rmiUrl = (String) iterator.next();
                String rmiUrlCacheName = extractCacheName(rmiUrl);
                if (!rmiUrlCacheName.equals(cache.getName())) {
                    continue;
                }
                CachePeer cachePeer = (CachePeer) peerUrls.get(rmiUrl);
                remoteCachePeers.add(cachePeer);
            }
        }
        return remoteCachePeers;
    }

    @Override
    protected boolean stale(final Date date) {
        // unused
        return false;
    }

    @Override
    public long getTimeForClusterToForm() {
        return UPDATE_INTERVAL_MILLIS * 2 + SHORT_DELAY;
    }

    public static class Metadata {
        @JsonProperty("instances")
        private final List<InstanceInfo> instances;

        @JsonCreator
        public Metadata(@JsonProperty("instances") List<InstanceInfo> instances) {
            this.instances = instances;
        }

        public List<InstanceInfo> getInstances() {
            return instances;
        }
    }

    public static class InstanceInfo {

        @JsonProperty("fqdn")
        private final String fqdn;

        public InstanceInfo(@JsonProperty("fqdn") String fqdn) {
            this.fqdn = fqdn;
        }

        public String getFqdn() {
            return fqdn;
        }
    }


}
