package io.cattle.platform.hazelcast.membership;

import io.cattle.platform.archaius.util.ArchaiusUtil;
import io.cattle.platform.core.model.ClusterMembership;
import io.cattle.platform.hazelcast.membership.dao.ClusterMembershipDAO;
import io.cattle.platform.json.JsonMapper;
import io.cattle.platform.object.ObjectManager;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import javax.annotation.PostConstruct;
import javax.inject.Inject;

import org.apache.cloudstack.managed.context.NoExceptionRunnable;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.hazelcast.nio.Address;
import com.hazelcast.spi.discovery.DiscoveryNode;
import com.hazelcast.spi.discovery.DiscoveryStrategy;
import com.hazelcast.spi.discovery.SimpleDiscoveryNode;
import com.hazelcast.spi.partitiongroup.PartitionGroupStrategy;
import com.netflix.config.DynamicBooleanProperty;
import com.netflix.config.DynamicIntProperty;
import com.netflix.config.DynamicStringProperty;

public class DBDiscovery extends NoExceptionRunnable implements DiscoveryStrategy, ClusterService {

    public static final DynamicStringProperty ADVERTISE_ADDRESS = ArchaiusUtil.getString("cluster.advertise.address");
    public static final DynamicStringProperty HTTP_PORT = ArchaiusUtil.getString("cluster.advertise.http.port");

    public static final DynamicStringProperty DEFAULT_HTTP_PORT = ArchaiusUtil.getString("cluster.default.http.port");
    public static final DynamicIntProperty DEFAULT_PORT = ArchaiusUtil.getInt("cluster.default.port");
    public static final DynamicIntProperty INTERVAL = ArchaiusUtil.getInt("cluster.checkin.seconds");
    public static final DynamicIntProperty MISSED_COUNT = ArchaiusUtil.getInt("cluster.checkin.misses");
    public static final DynamicStringProperty ID_FILE = ArchaiusUtil.getString("cluster.id.file");

    private static final DynamicBooleanProperty CLUSTERED = ArchaiusUtil.getBoolean("module.profile.hazelcast.eventing");

    private static final Logger log = LoggerFactory.getLogger("ConsoleStatus");

    @Inject
    ClusterMembershipDAO clusterMembershipDao;
    @Inject
    ObjectManager objectManager;
    @Inject
    JsonMapper jsonMapper;
    ScheduledExecutorService executorService;
    DiscoveryNode selfNode;

    Map<String, DiscoveryNode> hzNodes = new TreeMap<>();
    Map<Long, ClusteredMember> members = new TreeMap<>();
    String uuid = UUID.randomUUID().toString();
    ScheduledFuture<?> future;
    Map<Long, Checkin> heartbeats = new HashMap<>();
    boolean initial = true;
    boolean logStartup = true;

    @PostConstruct
    public void init() {
        try {
            readId();
            checkin();
        } catch (Exception e) {
            log.error("Failed to initialize discovery", e);
            throw new IllegalStateException(e);
        }
        initial = false;
        executorService = Executors.newSingleThreadScheduledExecutor();
        future = executorService.scheduleWithFixedDelay(this, INTERVAL.get(), INTERVAL.get(), TimeUnit.SECONDS);
        log.info("Checking cluster state on start-up");
        waitReady();
    }

    protected void readId() throws IOException {
        if (CLUSTERED.get()) {
            uuid = getLocalAddress();
            return;
        }

        String file = ID_FILE.get();
        if (StringUtils.isBlank(file)) {
            return;
        }

        File f = new File(file);
        if (f.exists()) {
            try (FileInputStream fis = new FileInputStream(f)) {
                uuid = IOUtils.toString(fis).trim();
            }
        } else {
            try (FileOutputStream fos = new FileOutputStream(f)) {
                IOUtils.write(uuid, fos, "UTF-8");
            }
        }
    }

    @Override
    public void start() {
    }

    @Override
    public synchronized Iterable<DiscoveryNode> discoverNodes() {
        return hzNodes.values();
    }

    @Override
    public void destroy() {
        future.cancel(true);
    }

    @Override
    public Map<String, Object> discoverLocalMetadata() {
        return new HashMap<>();
    }

    protected String getAddress(String address) {
        if (StringUtils.isBlank(address)) {
            address = "127.0.0.1";
        }
        if (!address.contains(":")) {
            return String.format("%s:%d", address, DEFAULT_PORT.get());
        }
        return address;
    }

    protected String getLocalAddress() {
        String address = ADVERTISE_ADDRESS.get();
        return getAddress(address);
    }

    public ClusterConfig getLocalConfig() {
        ClusterConfig config = new ClusterConfig();
        config.setClustered(CLUSTERED.get());
        config.setAdvertiseAddress(getLocalAddress());
        if (StringUtils.isBlank(HTTP_PORT.get())) {
            config.setHttpPort(Integer.parseInt(DEFAULT_HTTP_PORT.get()));
        } else {
            config.setHttpPort(Integer.parseInt(HTTP_PORT.get()));
        }
        return config;
    }

    @Override
    protected synchronized void doRun() throws Exception {
        try {
            checkin();
        } catch (Throwable t ) {
            log.error("Check-in failed", t);
            System.err.println("FATAL: Exiting due to failed cluster check-in");
            System.exit(1);
        }
    }

    public synchronized void checkin() throws Exception {
        Map<Long, ClusterMembership> activeSet = new TreeMap<>();
        ClusterConfig config = getLocalConfig();
        clusterMembershipDao.checkin(uuid, config, initial);

        for (ClusterMembership member : clusterMembershipDao.listMembers()) {
            Checkin checkin = heartbeats.get(member.getId());
            if (checkin == null) {
                checkin = new Checkin();
            }

            if (member.getHeartbeat().equals(checkin.heartbeat)) {
                checkin.count += 1;
            } else {
                checkin.count = 0;
            }

            if (checkin.count > MISSED_COUNT.get()) {
                clusterMembershipDao.delete(member);
            } else {
                activeSet.put(member.getId(), member);
            }

            checkin.heartbeat = member.getHeartbeat();
            heartbeats.put(member.getId(), checkin);
        }

        Iterator<Long> keyIter = heartbeats.keySet().iterator();
        while (keyIter.hasNext()) {
            Long key = keyIter.next();
            if (!activeSet.containsKey(key)) {
               keyIter.remove();
            }
        }

        setupHz(activeSet, config);
        setupMembers(activeSet, config);

        this.notifyAll();
    }

    protected void setupMembers(Map<Long, ClusterMembership> activeSet, ClusterConfig config) throws IOException {
        Map<Long, ClusteredMember> members = new TreeMap<>();

        for (Map.Entry<Long, ClusterMembership> entry : activeSet.entrySet()) {
            Long id = entry.getKey();
            ClusterMembership member = entry.getValue();
            ClusterConfig memberConfig = jsonMapper.readValue(member.getConfig(), ClusterConfig.class);
            members.put(id, new ClusteredMember(id, memberConfig, uuid.equals(member.getUuid()), member.getClustered()));
        }

        this.members = members;
    }

    protected void setupHz(Map<Long, ClusterMembership> activeSet, ClusterConfig selfConfig) {
        Map<String, DiscoveryNode> newNodes = new TreeMap<>();
        for (ClusterMembership member : activeSet.values()) {
            try {
                ClusterConfig config = jsonMapper.readValue(member.getConfig(), ClusterConfig.class);
                String address = getAddress(config.getAdvertiseAddress());
                String[] parts = address.split(":");

                newNodes.put(address, new SimpleDiscoveryNode(new Address(parts[0], Integer.parseInt(parts[1]))));
            } catch (Exception e) {
                log.error("Failed to register cluster member {} {}", member.getUuid(), member.getConfig());
            }
        }

        if (selfNode != null) {
            newNodes.put(selfConfig.getAdvertiseAddress(), selfNode);
        }

        if (!this.hzNodes.keySet().equals(newNodes.keySet())) {
            log.info("Cluster membership changed {}", newNodes.keySet());
        }

        this.hzNodes = newNodes;
    }

    @Override
    public PartitionGroupStrategy getPartitionGroupStrategy() {
        return null;
    }

    private static class Checkin {
        long heartbeat;
        long count;
    }

    public DiscoveryNode getSelfNode() {
        return selfNode;
    }

    public void setSelfNode(DiscoveryNode selfNode) {
        this.selfNode = selfNode;
    }

    @Override
    public synchronized boolean isMaster() {
        if (members.size() == 0) {
            return false;
        }

        return members.values().iterator().next().isSelf();
    }

    @Override
    public boolean waitReady() {
        while (!isReady()) {
            try {
                synchronized (this) {
                    this.wait();
                }
            } catch (InterruptedException e) {
                throw new IllegalStateException(e);
            }
        }

        return true;
    }

    protected synchronized boolean isReady() {
        if (isMaster()) {
            return true;
        }

        ClusterConfig local = getLocalConfig();
        if (!local.isClustered()) {
            if (logStartup) {
                log.info("Waiting to become master");
                logStartup = false;
            }
            // Non-clustered only are ready when they are master;
            return false;
        }

        ClusteredMember master = getMaster();

        if (master == null) {
            if (logStartup) {
                log.info("Waiting for a master");
            }
            return false;
        }

        // Clustered are not ready until master is clustered also
        if (master.isClustered()) {
            String[] parts = master.getConfig().getAdvertiseAddress().split(":");
            try (
                Socket socket = new Socket();
            ) {
                socket.connect(new InetSocketAddress(parts[0], Integer.parseInt(parts[1])), 5000);
                log.info("Connection test to master");
            } catch (IOException e) {
                log.error("Failed to connect to master at {}", master.getAdvertiseAddress());
                return false;
            }
            return true;
        }

        if (logStartup) {
            log.info("Waiting for non-clustered master to die");
            logStartup = false;
        }
        return false;
    }

    @Override
    public synchronized ClusteredMember getMaster() {
        if (members.size() == 0) {
            return null;
        }

        return members.values().iterator().next();
    }

}
