/*
 * 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.policy.followthesun;

import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.brooklyn.util.JavaGroovyEquivalents.elvis;
import static org.apache.brooklyn.util.JavaGroovyEquivalents.groovyTruth;

import java.util.Collection;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.brooklyn.api.entity.Entity;
import org.apache.brooklyn.api.entity.EntityLocal;
import org.apache.brooklyn.api.location.Location;
import org.apache.brooklyn.api.location.MachineProvisioningLocation;
import org.apache.brooklyn.api.sensor.AttributeSensor;
import org.apache.brooklyn.api.sensor.Sensor;
import org.apache.brooklyn.api.sensor.SensorEvent;
import org.apache.brooklyn.api.sensor.SensorEventListener;
import org.apache.brooklyn.core.entity.Attributes;
import org.apache.brooklyn.core.policy.AbstractPolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.brooklyn.policy.followthesun.FollowTheSunPool.ContainerItemPair;
import org.apache.brooklyn.policy.loadbalancing.Movable;
import org.apache.brooklyn.util.collections.MutableMap;
import org.apache.brooklyn.util.core.flags.SetFromFlag;

import com.google.common.base.Function;
import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.ThreadFactoryBuilder;

    // removed from catalog because it cannot currently be configured via catalog mechanisms - 
    // PolicySpec.create fails due to no no-arg constructor
    // TODO make model and parameters things which can be initialized from config then reinstate in catalog
//@Catalog(name="Follow the Sun", description="Policy for moving \"work\" around to follow the demand; "
//        + "the work can be any \"Movable\" entity")
public class FollowTheSunPolicy extends AbstractPolicy {

    private static final Logger LOG = LoggerFactory.getLogger(FollowTheSunPolicy.class);

    public static final String NAME = "Follow the Sun (Inter-Geography Latency Optimization)";

    @SetFromFlag(defaultVal="100")
    private long minPeriodBetweenExecs;
    
    @SetFromFlag
    private Function<Entity, Location> locationFinder;
    
    private final AttributeSensor<Map<? extends Movable, Double>> itemUsageMetric;
    private final FollowTheSunModel<Entity, Movable> model;
    private final FollowTheSunStrategy<Entity, Movable> strategy;
    private final FollowTheSunParameters parameters;
    
    private FollowTheSunPool poolEntity;
    
    private volatile ScheduledExecutorService executor;
    private final AtomicBoolean executorQueued = new AtomicBoolean(false);
    private volatile long executorTime = 0;
    private boolean loggedConstraintsIgnored = false;
    
    private final Function<Entity, Location> defaultLocationFinder = new Function<Entity, Location>() {
        @Override
        public Location apply(Entity e) {
            Collection<Location> locs = e.getLocations();
            if (locs.isEmpty()) return null;
            Location contender = Iterables.get(locs, 0);
            while (contender.getParent() != null && !(contender instanceof MachineProvisioningLocation)) {
                contender = contender.getParent();
            }
            return contender;
        }
    };
    
    private final SensorEventListener<Object> eventHandler = new SensorEventListener<Object>() {
        @Override
        public void onEvent(SensorEvent<Object> event) {
            if (LOG.isTraceEnabled()) LOG.trace("{} received event {}", FollowTheSunPolicy.this, event);
            Entity source = event.getSource();
            Object value = event.getValue();
            Sensor<?> sensor = event.getSensor();
            
            if (sensor.equals(itemUsageMetric)) {
                onItemMetricUpdated((Movable)source, (Map<? extends Movable, Double>) value, true);
            } else if (sensor.equals(Attributes.LOCATION_CHANGED)) {
                onContainerLocationUpdated(source, true);
            } else if (sensor.equals(FollowTheSunPool.CONTAINER_ADDED)) {
                onContainerAdded((Entity) value, true);
            } else if (sensor.equals(FollowTheSunPool.CONTAINER_REMOVED)) {
                onContainerRemoved((Entity) value, true);
            } else if (sensor.equals(FollowTheSunPool.ITEM_ADDED)) {
                onItemAdded((Movable) value, true);
            } else if (sensor.equals(FollowTheSunPool.ITEM_REMOVED)) {
                onItemRemoved((Movable) value, true);
            } else if (sensor.equals(FollowTheSunPool.ITEM_MOVED)) {
                ContainerItemPair pair = (ContainerItemPair) value;
                onItemMoved((Movable)pair.item, pair.container, true);
            }
        }
    };
    
    // FIXME parameters: use a more groovy way of doing it, that's consistent with other policies/entities?
    public FollowTheSunPolicy(AttributeSensor itemUsageMetric, 
            FollowTheSunModel<Entity, Movable> model, FollowTheSunParameters parameters) {
        this(MutableMap.of(), itemUsageMetric, model, parameters);
    }
    
    public FollowTheSunPolicy(Map props, AttributeSensor itemUsageMetric, 
            FollowTheSunModel<Entity, Movable> model, FollowTheSunParameters parameters) {
        super(props);
        this.itemUsageMetric = itemUsageMetric;
        this.model = model;
        this.parameters = parameters;
        this.strategy = new FollowTheSunStrategy<Entity, Movable>(model, parameters); // TODO: extract interface, inject impl
        this.locationFinder = elvis(locationFinder, defaultLocationFinder);
        
        // TODO Should re-use the execution manager's thread pool, somehow
        executor = Executors.newSingleThreadScheduledExecutor(newThreadFactory());
    }
    
    @Override
    public void setEntity(EntityLocal entity) {
        checkArgument(entity instanceof FollowTheSunPool, "Provided entity must be a FollowTheSunPool");
        super.setEntity(entity);
        this.poolEntity = (FollowTheSunPool) entity;
        
        // Detect when containers are added to or removed from the pool.
        subscriptions().subscribe(poolEntity, FollowTheSunPool.CONTAINER_ADDED, eventHandler);
        subscriptions().subscribe(poolEntity, FollowTheSunPool.CONTAINER_REMOVED, eventHandler);
        subscriptions().subscribe(poolEntity, FollowTheSunPool.ITEM_ADDED, eventHandler);
        subscriptions().subscribe(poolEntity, FollowTheSunPool.ITEM_REMOVED, eventHandler);
        subscriptions().subscribe(poolEntity, FollowTheSunPool.ITEM_MOVED, eventHandler);
        
        // Take heed of any extant containers.
        for (Entity container : poolEntity.getContainerGroup().getMembers()) {
            onContainerAdded(container, false);
        }
        for (Entity item : poolEntity.getItemGroup().getMembers()) {
            onItemAdded((Movable)item, false);
        }

        scheduleLatencyReductionJig();
    }
    
    @Override
    public void suspend() {
        // TODO unsubscribe from everything? And resubscribe on resume?
        super.suspend();
        if (executor != null) executor.shutdownNow();
        executorQueued.set(false);
    }
    
    @Override
    public void resume() {
        super.resume();
        executor = Executors.newSingleThreadScheduledExecutor(newThreadFactory());
        executorTime = 0;
        executorQueued.set(false);
    }
    
    private ThreadFactory newThreadFactory() {
        return new ThreadFactoryBuilder()
                .setNameFormat("brooklyn-followthesunpolicy-%d")
                .build();
    }

    private void scheduleLatencyReductionJig() {
        if (isRunning() && executorQueued.compareAndSet(false, true)) {
            long now = System.currentTimeMillis();
            long delay = Math.max(0, (executorTime + minPeriodBetweenExecs) - now);
            
            executor.schedule(new Runnable() {
                @Override
                public void run() {
                    try {
                        executorTime = System.currentTimeMillis();
                        executorQueued.set(false);
                        
                        if (LOG.isTraceEnabled()) LOG.trace("{} executing follow-the-sun migration-strategy", this);
                        strategy.rebalance();
                        
                    } catch (RuntimeException e) {
                        if (isRunning()) {
                            LOG.error("Error during latency-reduction-jig", e);
                        } else {
                            LOG.debug("Error during latency-reduction-jig, but no longer running", e);
                        }
                    }
                }},
                delay,
                TimeUnit.MILLISECONDS);
        }
    }
    
    private void onContainerAdded(Entity container, boolean rebalanceNow) {
        subscriptions().subscribe(container, Attributes.LOCATION_CHANGED, eventHandler);
        Location location = locationFinder.apply(container);
        
        if (LOG.isTraceEnabled()) LOG.trace("{} recording addition of container {} in location {}", new Object[] {this, container, location});
        model.onContainerAdded(container, location);
        
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onContainerRemoved(Entity container, boolean rebalanceNow) {
        if (LOG.isTraceEnabled()) LOG.trace("{} recording removal of container {}", this, container);
        model.onContainerRemoved(container);
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onItemAdded(Movable item, boolean rebalanceNow) {
        Entity parentContainer = item.getAttribute(Movable.CONTAINER);
        
        if (LOG.isTraceEnabled()) LOG.trace("{} recording addition of item {} in container {}", new Object[] {this, item, parentContainer});
        
        subscriptions().subscribe(item, itemUsageMetric, eventHandler);
        
        // Update the model, including the current metric value (if any).
        Map<? extends Movable, Double> currentValue = item.getAttribute(itemUsageMetric);
        boolean immovable = elvis(item.getConfig(Movable.IMMOVABLE), false);
        model.onItemAdded(item, parentContainer, immovable);

        if (currentValue != null) {
            model.onItemUsageUpdated(item, currentValue);
        }
        
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onItemRemoved(Movable item, boolean rebalanceNow) {
        if (LOG.isTraceEnabled()) LOG.trace("{} recording removal of item {}", this, item);
        subscriptions().unsubscribe(item);
        model.onItemRemoved(item);
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onItemMoved(Movable item, Entity parentContainer, boolean rebalanceNow) {
        if (LOG.isTraceEnabled()) LOG.trace("{} recording moving of item {} to {}", new Object[] {this, item, parentContainer});
        model.onItemMoved(item, parentContainer);
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onContainerLocationUpdated(Entity container, boolean rebalanceNow) {
        Location location = locationFinder.apply(container);
        if (LOG.isTraceEnabled()) LOG.trace("{} recording location for container {}, new value {}", new Object[] {this, container, location});
        model.onContainerLocationUpdated(container, location);
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    private void onItemMetricUpdated(Movable item, Map<? extends Movable, Double> newValues, boolean rebalanceNow) {
        if (LOG.isTraceEnabled()) LOG.trace("{} recording usage update for item {}, new value {}", new Object[] {this, item, newValues});
        model.onItemUsageUpdated(item, newValues);
        if (rebalanceNow) scheduleLatencyReductionJig();
    }
    
    @Override
    public String toString() {
        return getClass().getSimpleName() + (groovyTruth(name) ? "("+name+")" : "");
    }
}
