/*
 * 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.jclouds.profitbricks.compute;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.collect.Iterables.transform;
import static com.google.common.util.concurrent.Futures.allAsList;
import static com.google.common.util.concurrent.Futures.getUnchecked;
import static java.lang.String.format;
import static org.jclouds.Constants.PROPERTY_USER_THREADS;
import static org.jclouds.profitbricks.config.ProfitBricksComputeProperties.POLL_PREDICATE_DATACENTER;

import java.util.List;
import java.util.concurrent.Callable;

import javax.annotation.Resource;
import javax.inject.Named;
import javax.inject.Singleton;

import org.jclouds.compute.ComputeServiceAdapter;
import org.jclouds.compute.domain.Hardware;
import org.jclouds.compute.domain.HardwareBuilder;
import org.jclouds.compute.domain.Processor;
import org.jclouds.compute.domain.Template;
import org.jclouds.compute.domain.Volume;
import org.jclouds.compute.domain.internal.VolumeImpl;
import org.jclouds.compute.options.TemplateOptions;
import org.jclouds.compute.reference.ComputeServiceConstants;
import org.jclouds.compute.util.ComputeServiceUtils;
import org.jclouds.domain.Location;
import org.jclouds.domain.LocationScope;
import org.jclouds.domain.LoginCredentials;
import org.jclouds.logging.Logger;
import org.jclouds.profitbricks.ProfitBricksApi;
import org.jclouds.profitbricks.compute.concurrent.ProvisioningJob;
import org.jclouds.profitbricks.compute.concurrent.ProvisioningManager;
import org.jclouds.profitbricks.compute.function.ProvisionableToImage;
import org.jclouds.profitbricks.compute.strategy.TemplateWithDataCenter;
import org.jclouds.profitbricks.domain.AvailabilityZone;
import org.jclouds.profitbricks.domain.DataCenter;
import org.jclouds.profitbricks.domain.Image;
import org.jclouds.profitbricks.domain.Provisionable;
import org.jclouds.profitbricks.domain.Server;
import org.jclouds.profitbricks.domain.Snapshot;
import org.jclouds.profitbricks.domain.Storage;
import org.jclouds.profitbricks.features.ServerApi;
import org.jclouds.rest.ResourceNotFoundException;

import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.base.Supplier;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.inject.Inject;

import org.jclouds.util.PasswordGenerator;

@Singleton
public class ProfitBricksComputeServiceAdapter implements ComputeServiceAdapter<Server, Hardware, Provisionable, Location> {

   @Resource
   @Named(ComputeServiceConstants.COMPUTE_LOGGER)
   protected Logger logger = Logger.NULL;

   private final ProfitBricksApi api;
   private final Predicate<String> waitDcUntilAvailable;
   private final ListeningExecutorService executorService;
   private final ProvisioningJob.Factory jobFactory;
   private final ProvisioningManager provisioningManager;
   private final PasswordGenerator.Config passwordGenerator;

   private static final Integer DEFAULT_LAN_ID = 1;

   @Inject
   ProfitBricksComputeServiceAdapter(ProfitBricksApi api,
           @Named(POLL_PREDICATE_DATACENTER) Predicate<String> waitDcUntilAvailable,
           @Named(PROPERTY_USER_THREADS) ListeningExecutorService executorService,
           ProvisioningJob.Factory jobFactory,
           ProvisioningManager provisioningManager,
           PasswordGenerator.Config passwordGenerator) {
      this.api = api;
      this.waitDcUntilAvailable = waitDcUntilAvailable;
      this.executorService = executorService;
      this.jobFactory = jobFactory;
      this.provisioningManager = provisioningManager;
      this.passwordGenerator = passwordGenerator;
   }
   
   @Override
   public NodeAndInitialCredentials<Server> createNodeWithGroupEncodedIntoName(String group, String name, Template template) {
      checkArgument(template instanceof TemplateWithDataCenter, "This implementation requires a TemplateWithDataCenter");
      return createNodeWithGroupEncodedIntoName(group, name, TemplateWithDataCenter.class.cast(template));
   }

   protected NodeAndInitialCredentials<Server> createNodeWithGroupEncodedIntoName(String group, String name, TemplateWithDataCenter template) {
      checkArgument(template.getLocation().getScope() == LocationScope.ZONE, "Template must use a ZONE-scoped location");
      final String dataCenterId = template.getDataCenter().id();

      Hardware hardware = template.getHardware();

      TemplateOptions options = template.getOptions();
      final String loginUser = isNullOrEmpty(options.getLoginUser()) ? "root" : options.getLoginUser();
      final String password = options.hasLoginPassword() ? options.getLoginPassword() : passwordGenerator.generate();

      final org.jclouds.compute.domain.Image image = template.getImage();

      // provision all storages based on hardware
      List<? extends Volume> volumes = hardware.getVolumes();
      List<String> storageIds = Lists.newArrayListWithExpectedSize(volumes.size());

      int i = 1;
      for (final Volume volume : volumes)
         try {
            logger.trace("<< provisioning storage '%s'", volume);
            final Storage.Request.CreatePayload.Builder storageBuilder = Storage.Request.creatingBuilder();
            if (i == 1) {
               storageBuilder.mountImageId(image.getId());
               // we don't need to pass password to the API if we're using a snapshot
               Provisionable.Type provisionableType = Provisionable.Type.fromValue(
                       image.getUserMetadata().get(ProvisionableToImage.KEY_PROVISIONABLE_TYPE));
               if (provisionableType == Provisionable.Type.IMAGE)
                  storageBuilder.imagePassword(password);
            }
            storageBuilder.dataCenterId(dataCenterId)
                    .name(format("%s-disk-%d", name, i++))
                    .size(volume.getSize());

            String storageId = (String) provisioningManager.provision(jobFactory.create(dataCenterId, new Supplier<Object>() {

               @Override
               public Object get() {
                  return api.storageApi().createStorage(storageBuilder.build());
               }
            }));

            storageIds.add(storageId);
            logger.trace(">> provisioning complete for storage. returned id='%s'", storageId);
         } catch (Exception ex) {
            if (i - 1 == 1) // if first storage (one with image) provisioning fails; stop method
               throw Throwables.propagate(ex);
            logger.warn(ex, ">> failed to provision storage. skipping..");
         }

      int lanId = DEFAULT_LAN_ID;
      if (options.getNetworks() != null)
         try {
            String networkId = Iterables.get(options.getNetworks(), 0);
            lanId = Integer.parseInt(networkId);
         } catch (Exception ex) {
            logger.warn("no valid network id found from options. using default id='%d'", DEFAULT_LAN_ID);
         }

      Double cores = ComputeServiceUtils.getCores(hardware);

      // provision server and connect boot storage (first provisioned)
      String serverId = null;
      try {
         String storageBootDeviceId = Iterables.get(storageIds, 0); // must have atleast 1
         final Server.Request.CreatePayload serverRequest = Server.Request.creatingBuilder()
                 .dataCenterId(dataCenterId)
                 .name(name)
                 .bootFromStorageId(storageBootDeviceId)
                 .cores(cores.intValue())
                 .ram(hardware.getRam())
                 .availabilityZone(AvailabilityZone.AUTO)
                 .hasInternetAccess(true)
                 .lanId(lanId)
                 .build();
         logger.trace("<< provisioning server '%s'", serverRequest);

         serverId = (String) provisioningManager.provision(jobFactory.create(dataCenterId, new Supplier<Object>() {

            @Override
            public Object get() {
               return api.serverApi().createServer(serverRequest);
            }
         }));
         logger.trace(">> provisioning complete for server. returned id='%s'", serverId);

      } catch (Exception ex) {
         logger.error(ex, ">> failed to provision server. rollbacking..");
         destroyStorages(storageIds, dataCenterId);
         throw Throwables.propagate(ex);
      }

      // connect the rest of storages to server; delete if fails
      final int storageCount = storageIds.size();
      for (int j = 1; j < storageCount; j++) { // skip first; already connected
         String storageId = storageIds.get(j);
         try {
            logger.trace("<< connecting storage '%s' to server '%s'", storageId, serverId);
            final Storage.Request.ConnectPayload request = Storage.Request.connectingBuilder()
                    .storageId(storageId)
                    .serverId(serverId)
                    .build();

            provisioningManager.provision(jobFactory.create(group, new Supplier<Object>() {

               @Override
               public Object get() {
                  return api.storageApi().connectStorageToServer(request);
               }
            }));

            logger.trace(">> storage connected.");
         } catch (Exception ex) {
            // delete unconnected storage
            logger.warn(ex, ">> failed to connect storage '%s'. deleting..", storageId);
            destroyStorage(storageId, dataCenterId);
         }
      }

      // Last paranoid check
      waitDcUntilAvailable.apply(dataCenterId);

      LoginCredentials serverCredentials = LoginCredentials.builder()
              .user(loginUser)
              .password(password)
              .build();

      Server server = getNode(serverId);

      return new NodeAndInitialCredentials<Server>(server, serverId, serverCredentials);
   }

   @Override
   public Iterable<Hardware> listHardwareProfiles() {
      // Max [cores=48] [disk size per storage=2048GB] [ram=200704 MB]
      List<Hardware> hardwares = Lists.newArrayList();
      for (int core = 1; core <= 48; core++)
         for (int ram : new int[]{1024, 2 * 1024, 4 * 1024, 8 * 1024,
            10 * 1024, 16 * 1024, 24 * 1024, 28 * 1024, 32 * 1024})
            for (float size : new float[]{10, 20, 30, 50, 80, 100, 150, 200, 250, 500}) {
               String id = String.format("cpu=%d,ram=%s,disk=%f", core, ram, size);
               hardwares.add(new HardwareBuilder()
                       .ids(id)
                       .ram(ram)
                       .hypervisor("kvm")
                       .name(id)
                       .processor(new Processor(core, 1d))
                       .volume(new VolumeImpl(size, true, true))
                       .build());
            }
      return hardwares;
   }

   @Override
   public Iterable<Provisionable> listImages() {
      // fetch images..
      ListenableFuture<List<Image>> images = executorService.submit(new Callable<List<Image>>() {

         @Override
         public List<Image> call() throws Exception {
            logger.trace("<< fetching images..");
            // Filter HDD types only, since JClouds doesn't have a concept of "CD-ROM" anyway
            Iterable<Image> filteredImages = Iterables.filter(api.imageApi().getAllImages(), new Predicate<Image>() {

               @Override
               public boolean apply(Image image) {
                  return image.type() == Image.Type.HDD;
               }
            });
            logger.trace(">> images fetched.");

            return ImmutableList.copyOf(filteredImages);
         }

      });
      // and snapshots at the same time
      ListenableFuture<List<Snapshot>> snapshots = executorService.submit(new Callable<List<Snapshot>>() {

         @Override
         public List<Snapshot> call() throws Exception {
            logger.trace("<< fetching snapshots");
            List<Snapshot> remoteSnapshots = api.snapshotApi().getAllSnapshots();
            logger.trace(">> snapshots feched.");

            return remoteSnapshots;
         }

      });

      return Iterables.concat(getUnchecked(images), getUnchecked(snapshots));
   }

   @Override
   public Provisionable getImage(String id) {
      // try search images
      logger.trace("<< searching for image with id=%s", id);
      try {
         Image image = api.imageApi().getImage(id);
         if (image != null) {
            logger.trace(">> found image [%s].", image.name());
            return image;
         }
      } catch (Exception ex) {
         logger.warn(ex, ">> unexpected error getting image. Trying to get as a snapshot...");
      }
      
      // try search snapshots
      logger.trace("<< not found from images. searching for snapshot with id=%s", id);
      Snapshot snapshot = api.snapshotApi().getSnapshot(id);
      if (snapshot != null) {
         logger.trace(">> found snapshot [%s]", snapshot.name());
         return snapshot;
      }
      throw new ResourceNotFoundException("No image/snapshot with id '" + id + "' was found");
   }

   @Override
   public Iterable<Location> listLocations() {
      // Will never be called
      throw new UnsupportedOperationException("Locations are configured in jclouds properties");
   }

   @Override
   public Server getNode(String id) {
      logger.trace("<< searching for server with id=%s", id);

      Server server = api.serverApi().getServer(id);
      if (server != null)
         logger.trace(">> found server [%s]", server.name());
      return server;
   }

   @Override
   public void destroyNode(String nodeId) {
      ServerApi serverApi = api.serverApi();
      Server server = serverApi.getServer(nodeId);
      if (server != null) {
         String dataCenterId = server.dataCenter().id();
         for (Storage storage : server.storages())
            destroyStorage(storage.id(), dataCenterId);

         try {
            destroyServer(nodeId, dataCenterId);
         } catch (Exception ex) {
            logger.warn(ex, ">> failed to delete server with id=%s", nodeId);
         }
      }
   }

   @Override
   public void rebootNode(final String id) {
      // Fail pre-emptively if not found
      final Server node = getRequiredNode(id);
      final DataCenter dataCenter = node.dataCenter();
      provisioningManager.provision(jobFactory.create(dataCenter.id(), new Supplier<Object>() {

         @Override
         public Object get() {
            api.serverApi().resetServer(id);

            return node;
         }
      }));
   }

   @Override
   public void resumeNode(final String id) {
      final Server node = getRequiredNode(id);
      if (node.status() == Server.Status.RUNNING)
         return;

      final DataCenter dataCenter = node.dataCenter();
      provisioningManager.provision(jobFactory.create(dataCenter.id(), new Supplier<Object>() {

         @Override
         public Object get() {
            api.serverApi().startServer(id);

            return node;
         }
      }));
   }

   @Override
   public void suspendNode(final String id) {
      final Server node = getRequiredNode(id);
      // Intentionally didn't include SHUTDOWN (only achieved via UI; soft-shutdown). 
      // A SHUTOFF server is no longer billed, so we execute method for all other status
      if (node.status() == Server.Status.SHUTOFF)
         return;

      final DataCenter dataCenter = node.dataCenter();
      provisioningManager.provision(jobFactory.create(dataCenter.id(), new Supplier<Object>() {

         @Override
         public Object get() {
            api.serverApi().stopServer(id);

            return node;
         }
      }));
   }

   @Override
   public Iterable<Server> listNodes() {
      logger.trace(">> fetching all servers..");
      List<Server> servers = api.serverApi().getAllServers();
      logger.trace(">> servers fetched.");
      return servers;
   }

   @Override
   public Iterable<Server> listNodesByIds(final Iterable<String> ids) {
      // Only fetch the requested nodes. Do it in parallel.
      ListenableFuture<List<Server>> futures = allAsList(transform(ids,
              new Function<String, ListenableFuture<Server>>() {

                 @Override
                 public ListenableFuture<Server> apply(final String input) {
                    return executorService.submit(new Callable<Server>() {

                       @Override
                       public Server call() throws Exception {
                          return getNode(input);
                       }
                    });
                 }
              }));

      return getUnchecked(futures);
   }

   private void destroyServer(final String serverId, final String dataCenterId) {
      try {
         logger.trace("<< deleting server with id=%s", serverId);
         provisioningManager.provision(jobFactory.create(dataCenterId, new Supplier<Object>() {

            @Override
            public Object get() {
               api.serverApi().deleteServer(serverId);

               return serverId;
            }
         }));
         logger.trace(">> server '%s' deleted.", serverId);
      } catch (Exception ex) {
         logger.warn(ex, ">> failed to delete server with id=%s", serverId);
      }
   }

   private void destroyStorages(List<String> storageIds, String dataCenterId) {
      for (String storageId : storageIds)
         destroyStorage(storageId, dataCenterId);
   }

   private void destroyStorage(final String storageId, final String dataCenterId) {
      try {
         logger.trace("<< deleting storage with id=%s", storageId);
         provisioningManager.provision(jobFactory.create(dataCenterId, new Supplier<Object>() {

            @Override
            public Object get() {
               api.storageApi().deleteStorage(storageId);

               return storageId;
            }
         }));
         logger.trace(">> storage '%s' deleted.", storageId);
      } catch (Exception ex) {
         logger.warn(ex, ">> failed to delete storage with id=%s", storageId);
      }
   }

   private Server getRequiredNode(String nodeId) {
      Server node = getNode(nodeId);
      if (node == null)
         throw new ResourceNotFoundException("Node with id'" + nodeId + "' was not found.");
      return node;
   }
}
