/*
 * 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.brooklyn.entity.database.mysql;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

import javax.annotation.Nullable;

import org.apache.brooklyn.api.entity.Entity;
import org.apache.brooklyn.api.entity.EntitySpec;
import org.apache.brooklyn.api.location.Location;
import org.apache.brooklyn.api.sensor.AttributeSensor;
import org.apache.brooklyn.api.sensor.SensorEvent;
import org.apache.brooklyn.api.sensor.SensorEventListener;
import org.apache.brooklyn.config.ConfigKey;
import org.apache.brooklyn.core.entity.Attributes;
import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic.ServiceNotUpLogic;
import org.apache.brooklyn.core.mgmt.BrooklynTaskTags;
import org.apache.brooklyn.core.sensor.Sensors;
import org.apache.brooklyn.enricher.stock.Enrichers;
import org.apache.brooklyn.entity.group.DynamicClusterImpl;
import org.apache.brooklyn.feed.function.FunctionFeed;
import org.apache.brooklyn.feed.function.FunctionPollConfig;
import org.apache.brooklyn.util.collections.CollectionFunctionals;
import org.apache.brooklyn.util.core.ResourceUtils;
import org.apache.brooklyn.util.core.task.DynamicTasks;
import org.apache.brooklyn.util.core.task.TaskBuilder;
import org.apache.brooklyn.util.guava.Functionals;
import org.apache.brooklyn.util.guava.IfFunctions;
import org.apache.brooklyn.util.text.Identifiers;
import org.apache.brooklyn.util.text.StringPredicates;
import org.apache.brooklyn.util.text.Strings;
import org.apache.brooklyn.util.time.Duration;

import com.google.common.base.Function;
import com.google.common.base.Functions;
import com.google.common.base.Predicates;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.reflect.TypeToken;

// https://dev.mysql.com/doc/refman/5.7/en/replication-howto.html

// TODO SSL connection between master and slave
// TODO Promote slave to master
public class MySqlClusterImpl extends DynamicClusterImpl implements MySqlCluster {
    private static final AttributeSensor<Boolean> NODE_REPLICATION_INITIALIZED = Sensors.newBooleanSensor("mysql.replication_initialized");

    private static final String MASTER_CONFIG_URL = "classpath:///org/apache/brooklyn/entity/database/mysql/mysql_master.conf";
    private static final String SLAVE_CONFIG_URL = "classpath:///org/apache/brooklyn/entity/database/mysql/mysql_slave.conf";
    protected static final int MASTER_SERVER_ID = 1;

    @SuppressWarnings("serial")
    private static final AttributeSensor<Supplier<Integer>> SLAVE_NEXT_SERVER_ID = Sensors.newSensor(new TypeToken<Supplier<Integer>>() {},
            "mysql.slave.next_server_id", "Returns the ID of the next slave server");
    @SuppressWarnings("serial")
    protected static final AttributeSensor<Map<String, String>> SLAVE_ID_ADDRESS_MAPPING = Sensors.newSensor(new TypeToken<Map<String, String>>() {},
            "mysql.slave.id_address_mapping", "Maps slave entity IDs to SUBNET_ADDRESS, so the address is known at member remove time.");

    @Override
    public void init() {
        super.init();
        // Set id supplier in attribute so it is serialized
        sensors().set(SLAVE_NEXT_SERVER_ID, new NextServerIdSupplier());
        sensors().set(SLAVE_ID_ADDRESS_MAPPING, new ConcurrentHashMap<String, String>());
        if (getConfig(SLAVE_PASSWORD) == null) {
            sensors().set(SLAVE_PASSWORD, Identifiers.makeRandomId(8));
        } else {
            sensors().set(SLAVE_PASSWORD, getConfig(SLAVE_PASSWORD));
        }
        initSubscriptions();
    }

    @Override
    public void rebind() {
        super.rebind();
        initSubscriptions();
    }

    private void initSubscriptions() {
        subscriptions().subscribeToMembers(this, MySqlNode.SERVICE_PROCESS_IS_RUNNING, new NodeRunningListener(this));
        subscriptions().subscribe(this, MEMBER_REMOVED, new MemberRemovedListener());
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
    @Override
    protected void initEnrichers() {
        super.initEnrichers();
        propagateMasterAttribute(MySqlNode.HOSTNAME);
        propagateMasterAttribute(MySqlNode.ADDRESS);
        propagateMasterAttribute(MySqlNode.SUBNET_HOSTNAME);
        propagateMasterAttribute(MySqlNode.SUBNET_ADDRESS);
        propagateMasterAttribute(MySqlNode.MYSQL_PORT);
        propagateMasterAttribute(MySqlNode.DATASTORE_URL);

        enrichers().add(Enrichers.builder()
                .aggregating(MySqlNode.DATASTORE_URL)
                .publishing(SLAVE_DATASTORE_URL_LIST)
                .computing((Function<Collection<String>, List<String>>)(Function)Functions.identity())
                .entityFilter(Predicates.not(MySqlClusterUtils.IS_MASTER))
                .fromMembers()
                .build());

        enrichers().add(Enrichers.builder()
                .aggregating(MySqlNode.QUERIES_PER_SECOND_FROM_MYSQL)
                .publishing(QUERIES_PER_SECOND_FROM_MYSQL_PER_NODE)
                .fromMembers()
                .computingAverage()
                .defaultValueForUnreportedSensors(0d)
                .build());
    }

    private void propagateMasterAttribute(AttributeSensor<?> att) {
        enrichers().add(Enrichers.builder()
                .aggregating(att)
                .publishing(att)
                .computing(IfFunctions.ifPredicate(CollectionFunctionals.notEmpty())
                        .apply(CollectionFunctionals.firstElement())
                        .defaultValue(null))
                .entityFilter(MySqlClusterUtils.IS_MASTER)
                .build());
    }

    @Override
    protected EntitySpec<?> getFirstMemberSpec() {
        final EntitySpec<?> firstMemberSpec = super.getFirstMemberSpec();
        if (firstMemberSpec != null) {
            return applyDefaults(firstMemberSpec, Suppliers.ofInstance(MASTER_SERVER_ID), MASTER_CONFIG_URL);
        }

        final EntitySpec<?> memberSpec = super.getMemberSpec();
        if (memberSpec != null) {
            return applyDefaults(memberSpec, Suppliers.ofInstance(MASTER_SERVER_ID), MASTER_CONFIG_URL);
        }

        return EntitySpec.create(MySqlNode.class)
                .displayName("MySql Master")
                .configure(MySqlNode.MYSQL_SERVER_ID, MASTER_SERVER_ID)
                .configure(MySqlNode.TEMPLATE_CONFIGURATION_URL, MASTER_CONFIG_URL);
    }

    @Override
    protected EntitySpec<?> getMemberSpec() {
        Supplier<Integer> serverIdSupplier = getAttribute(SLAVE_NEXT_SERVER_ID);

        EntitySpec<?> spec = super.getMemberSpec();
        if (spec != null) {
            return applyDefaults(spec, serverIdSupplier, SLAVE_CONFIG_URL);
        }

        return EntitySpec.create(MySqlNode.class)
                .displayName("MySql Slave")
                // Slave server IDs will not be linear because getMemberSpec not always results in createNode (result discarded)
                .configure(MySqlNode.MYSQL_SERVER_ID, serverIdSupplier.get())
                .configure(MySqlNode.TEMPLATE_CONFIGURATION_URL, SLAVE_CONFIG_URL);
    }

    private EntitySpec<?> applyDefaults(EntitySpec<?> spec, Supplier<Integer> serverId, String configUrl) {
        boolean needsServerId = !isKeyConfigured(spec, MySqlNode.MYSQL_SERVER_ID);
        boolean needsConfigUrl = !isKeyConfigured(spec, MySqlNode.TEMPLATE_CONFIGURATION_URL.getConfigKey());
        if (needsServerId || needsConfigUrl) {
            EntitySpec<?> clonedSpec = EntitySpec.create(spec);
            if (needsServerId) {
                clonedSpec.configure(MySqlNode.MYSQL_SERVER_ID, serverId.get());
            }
            if (needsConfigUrl) {
                clonedSpec.configure(MySqlNode.TEMPLATE_CONFIGURATION_URL, configUrl);
            }
            return clonedSpec;
        } else {
            return spec;
        }
    }

    private boolean isKeyConfigured(EntitySpec<?> spec, ConfigKey<?> key) {
        return spec.getConfig().containsKey(key) || spec.getFlags().containsKey(key.getName());
    }

    @Override
    protected Entity createNode(Location loc, Map<?, ?> flags) {
        MySqlNode node = (MySqlNode) super.createNode(loc, flags);
        if (!MySqlClusterUtils.IS_MASTER.apply(node)) {
            ServiceNotUpLogic.updateNotUpIndicator(node, MySqlSlave.SLAVE_HEALTHY, "Replication not started");

            addFeed(FunctionFeed.builder()
                .entity(node)
                .period(Duration.FIVE_SECONDS)
                .poll(FunctionPollConfig.forSensor(MySqlSlave.SLAVE_HEALTHY)
                        .callable(new SlaveStateCallable(node))
                        .checkSuccess(StringPredicates.isNonBlank())
                        .onSuccess(new SlaveStateParser(node))
                        .setOnFailure(false)
                        .description("Polls SHOW SLAVE STATUS"))
                .build());

            node.enrichers().add(Enrichers.builder().updatingMap(Attributes.SERVICE_NOT_UP_INDICATORS)
                    .from(MySqlSlave.SLAVE_HEALTHY)
                    .computing(Functionals.ifNotEquals(true).value("Slave replication status is not healthy") )
                    .build());
        }
        return node;
    }

    public static class SlaveStateCallable implements Callable<String> {
        private MySqlNode slave;
        public SlaveStateCallable(MySqlNode slave) {
            this.slave = slave;
        }

        @Override
        public String call() throws Exception {
            if (Boolean.TRUE.equals(slave.getAttribute(MySqlNode.SERVICE_PROCESS_IS_RUNNING))) {
                return MySqlClusterUtils.executeSqlOnNode(slave, "SHOW SLAVE STATUS \\G");
            } else {
                return null;
            }
        }

    }

    public static class SlaveStateParser implements Function<String, Boolean> {
        private Entity slave;

        public SlaveStateParser(Entity slave) {
            this.slave = slave;
        }

        @Override
        public Boolean apply(String result) {
            Map<String, String> status = MySqlRowParser.parseSingle(result);
            String secondsBehindMaster = status.get("Seconds_Behind_Master");
            if (secondsBehindMaster != null && !"NULL".equals(secondsBehindMaster)) {
                slave.sensors().set(MySqlSlave.SLAVE_SECONDS_BEHIND_MASTER, new Integer(secondsBehindMaster));
            }
            return "Yes".equals(status.get("Slave_IO_Running")) && "Yes".equals(status.get("Slave_SQL_Running"));
        }

    }

    private static class NextServerIdSupplier implements Supplier<Integer> {
        private AtomicInteger nextId = new AtomicInteger(MASTER_SERVER_ID+1);

        @Override
        public Integer get() {
            return nextId.getAndIncrement();
        }
    }

    // ============= Member Init =============

    // The task is executed separately from the start effector, so failing here
    // will not fail the start effector as well, but it will eventually time out
    // because replication is not started.
    // Would be nice to be able to plug in to the entity lifecycle!

    private static final class NodeRunningListener implements SensorEventListener<Boolean> {
        private MySqlCluster cluster;
        private Semaphore lock = new Semaphore(1);

        public NodeRunningListener(MySqlCluster cluster) {
            this.cluster = cluster;
        }

        @Override
        public void onEvent(SensorEvent<Boolean> event) {
            final MySqlNode node = (MySqlNode) event.getSource();
            if (Boolean.TRUE.equals(event.getValue()) &&
                    // We are interested in SERVICE_PROCESS_IS_RUNNING only while haven't come online yet.
                    // Probably will get several updates while replication is initialized so an additional
                    // check is needed whether we have already seen this.
                    Boolean.FALSE.equals(node.getAttribute(MySqlNode.SERVICE_UP)) &&
                    !Boolean.TRUE.equals(node.getAttribute(NODE_REPLICATION_INITIALIZED))) {

                // Events executed sequentially so no need to synchronize here.
                node.sensors().set(NODE_REPLICATION_INITIALIZED, Boolean.TRUE);

                final Runnable nodeInitTaskBody;
                if (MySqlClusterUtils.IS_MASTER.apply(node)) {
                    nodeInitTaskBody = new InitMasterTaskBody(cluster, node);
                } else {
                    nodeInitTaskBody = new InitSlaveTaskBody(cluster, node, lock);
                }

                DynamicTasks.submitTopLevelTask(TaskBuilder.builder()
                        .displayName("setup master-slave replication")
                        .body(nodeInitTaskBody)
                        .tag(BrooklynTaskTags.NON_TRANSIENT_TASK_TAG)
                        .build(),
                        node);
            }
        }

    }
    
    private static class InitMasterTaskBody implements Runnable {
        private MySqlNode master;
        private MySqlCluster cluster;
        public InitMasterTaskBody(MySqlCluster cluster, MySqlNode master) {
            this.cluster = cluster;
            this.master = master;
        }

        @Override
        public void run() {
            String binLogInfo = MySqlClusterUtils.executeSqlOnNode(master, "FLUSH TABLES WITH READ LOCK;SHOW MASTER STATUS \\G UNLOCK TABLES;");
            Map<String, String> status = MySqlRowParser.parseSingle(binLogInfo);
            String file = status.get("File");
            String position = status.get("Position");
            if (file != null && position != null) {
                cluster.sensors().set(MySqlCluster.REPLICATION_LAST_SLAVE_SNAPSHOT, new ReplicationSnapshot(null, null, file, Integer.parseInt(position)));
            }

            //NOTE: Will be executed on each start, analogously to the standard CREATION_SCRIPT config
            String creationScript = getDatabaseCreationScriptAsString(master);
            if (creationScript != null) {
                master.invoke(MySqlNode.EXECUTE_SCRIPT, ImmutableMap.of("commands", creationScript));
            }
        }

        @Nullable private static String getDatabaseCreationScriptAsString(Entity entity) {
            String url = entity.getConfig(MySqlMaster.MASTER_CREATION_SCRIPT_URL);
            if (!Strings.isBlank(url))
                return new ResourceUtils(entity).getResourceAsString(url);
            String contents = entity.getConfig(MySqlMaster.MASTER_CREATION_SCRIPT_CONTENTS);
            if (!Strings.isBlank(contents))
                return contents;
            return null;
        }
    }

    // ============= Member Remove =============

    public class MemberRemovedListener implements SensorEventListener<Entity> {
        @Override
        public void onEvent(SensorEvent<Entity> event) {
            MySqlCluster cluster = (MySqlCluster) event.getSource();
            Entity node = event.getValue();
            String slaveAddress = cluster.getAttribute(SLAVE_ID_ADDRESS_MAPPING).remove(node.getId());
            if (slaveAddress != null) {
                // Could already be gone if stopping the entire app - let it throw an exception
                MySqlNode master = (MySqlNode) Iterables.find(cluster.getMembers(), MySqlClusterUtils.IS_MASTER);
                String username = MySqlClusterUtils.validateSqlParam(cluster.getConfig(SLAVE_USERNAME));
                MySqlClusterUtils.executeSqlOnNodeAsync(master, String.format("DROP USER '%s'@'%s';", username, slaveAddress));
            }
        }
    }

}
