/*
 * 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
 *
 *   https://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.accumulo.tserver.compactions;

import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.NANOSECONDS;

import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.apache.accumulo.core.conf.Property;
import org.apache.accumulo.core.data.TableId;
import org.apache.accumulo.core.dataImpl.KeyExtent;
import org.apache.accumulo.core.metadata.schema.ExternalCompactionId;
import org.apache.accumulo.core.spi.compaction.CompactionExecutorId;
import org.apache.accumulo.core.spi.compaction.CompactionKind;
import org.apache.accumulo.core.spi.compaction.CompactionServiceId;
import org.apache.accumulo.core.spi.compaction.CompactionServices;
import org.apache.accumulo.core.tabletserver.thrift.TCompactionQueueSummary;
import org.apache.accumulo.core.util.Pair;
import org.apache.accumulo.core.util.Retry;
import org.apache.accumulo.core.util.compaction.CompactionExecutorIdImpl;
import org.apache.accumulo.core.util.compaction.CompactionServicesConfig;
import org.apache.accumulo.core.util.threads.Threads;
import org.apache.accumulo.server.ServerContext;
import org.apache.accumulo.tserver.OpeningAndOnlineCompactables;
import org.apache.accumulo.tserver.compactions.CompactionExecutor.CType;
import org.apache.accumulo.tserver.metrics.CompactionExecutorsMetrics;
import org.apache.accumulo.tserver.tablet.Tablet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Preconditions;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Sets;

public class CompactionManager {

  private static final Logger log = LoggerFactory.getLogger(CompactionManager.class);

  private final Supplier<OpeningAndOnlineCompactables> compactables;
  private volatile Map<CompactionServiceId,CompactionService> services;

  private final LinkedBlockingQueue<Compactable> compactablesToCheck = new LinkedBlockingQueue<>();

  private final long maxTimeBetweenChecks;

  private final ServerContext context;

  private CompactionServicesConfig currentCfg;

  private long lastConfigCheckTime = System.nanoTime();

  private final CompactionExecutorsMetrics ceMetrics;

  private String lastDeprecationWarning = "";

  private final Map<CompactionExecutorId,ExternalCompactionExecutor> externalExecutors;

  private final Map<ExternalCompactionId,ExtCompInfo> runningExternalCompactions;

  // use to limit logging of unknown compaction services
  private final Cache<Pair<TableId,CompactionServiceId>,Long> unknownCompactionServiceErrorCache;

  static class ExtCompInfo {
    final KeyExtent extent;
    final CompactionExecutorId executor;

    public ExtCompInfo(KeyExtent extent, CompactionExecutorId executor) {
      this.extent = extent;
      this.executor = executor;
    }
  }

  private void warnAboutDeprecation(String warning) {
    if (!warning.equals(lastDeprecationWarning)) {
      log.warn(warning);
      lastDeprecationWarning = warning;
    }
  }

  private void mainLoop() {
    long lastCheckAllTime = System.nanoTime();

    long increment = Math.max(1, maxTimeBetweenChecks / 10);

    var retryFactory = Retry.builder().infiniteRetries().retryAfter(increment, MILLISECONDS)
        .incrementBy(increment, MILLISECONDS).maxWait(maxTimeBetweenChecks, MILLISECONDS)
        .backOffFactor(1.07).logInterval(1, MINUTES).createFactory();
    var retry = retryFactory.createRetry();
    Compactable last = null;

    while (true) {
      try {
        long passed = NANOSECONDS.toMillis(System.nanoTime() - lastCheckAllTime);
        if (passed >= maxTimeBetweenChecks) {
          // take a snapshot of what is currently running
          HashMap<ExternalCompactionId,ExtCompInfo> runningEcids =
              new HashMap<>(runningExternalCompactions);
          // Get a snapshot of the tablets that are online and opening, this must be obtained after
          // getting the runningExternalCompactions snapshot above. If it were obtained before then
          // an opening tablet could add itself to runningExternalCompactions after this code
          // obtained the snapshot of opening and online tablets and this code would remove it.
          var compactablesSnapshot = compactables.get();
          for (Tablet tablet : compactablesSnapshot.online.values()) {
            Compactable compactable = tablet.asCompactable();
            last = compactable;
            submitCompaction(compactable);
            // remove anything from snapshot that tablets know are running
            compactable.getExternalCompactionIds(runningEcids::remove);
          }
          lastCheckAllTime = System.nanoTime();

          // remove any tablets that are currently opening, these may have been added to
          // runningExternalCompactions while in the process of opening
          runningEcids.values()
              .removeIf(extCompInfo -> compactablesSnapshot.opening.contains(extCompInfo.extent));

          // anything left in the snapshot is unknown to any tablet and should be removed if it
          // still exists
          runningEcids.forEach((ecid, info) -> log.debug(
              "Removing unknown external compaction {} {} from runningExternalCompactions", ecid,
              info.extent));
          runningExternalCompactions.keySet().removeAll(runningEcids.keySet());
        } else {
          var compactable = compactablesToCheck.poll(maxTimeBetweenChecks - passed, MILLISECONDS);
          if (compactable != null) {
            last = compactable;
            submitCompaction(compactable);
          }
        }

        last = null;
        if (retry.hasRetried()) {
          retry = retryFactory.createRetry();
        }

        checkForConfigChanges(false);

      } catch (Exception e) {
        var extent = last == null ? null : last.getExtent();
        log.warn("Failed to compact {} ", extent, e);
        retry.useRetry();
        try {
          retry.waitForNextAttempt(log, "compaction initiation loop");
        } catch (InterruptedException e1) {
          Thread.currentThread().interrupt();
          log.debug("Retry interrupted", e1);
        }
      }
    }
  }

  /**
   * Get each configured service for the compactable tablet and submit for compaction
   */
  private void submitCompaction(Compactable compactable) {
    for (CompactionKind ctype : CompactionKind.values()) {
      var csid = compactable.getConfiguredService(ctype);
      var service = services.get(csid);
      if (service == null) {
        checkForConfigChanges(true);
        service = services.get(csid);
        if (service == null) {
          var cacheKey = new Pair<>(compactable.getTableId(), csid);
          var last = unknownCompactionServiceErrorCache.getIfPresent(cacheKey);
          if (last == null) {
            // have not logged an error recently for this, so lets log one
            log.error(
                "Tablet {} returned non-existent compaction service {} for compaction type {}.  Check"
                    + " the table compaction dispatcher configuration. No compactions will happen"
                    + " until the configuration is fixed. This log message is temporarily suppressed for the"
                    + " entire table.",
                compactable.getExtent(), csid, ctype);
            unknownCompactionServiceErrorCache.put(cacheKey, System.currentTimeMillis());
          }
        }
      }

      if (service != null) {
        service.submitCompaction(ctype, compactable, compactablesToCheck::add);
      }
    }
  }

  public CompactionManager(Supplier<OpeningAndOnlineCompactables> compactables,
      ServerContext context, CompactionExecutorsMetrics ceMetrics) {
    this.compactables = compactables;

    this.currentCfg =
        new CompactionServicesConfig(context.getConfiguration(), this::warnAboutDeprecation);

    this.context = context;

    this.ceMetrics = ceMetrics;

    this.externalExecutors = new ConcurrentHashMap<>();

    this.runningExternalCompactions = new ConcurrentHashMap<>();

    Map<CompactionServiceId,CompactionService> tmpServices = new HashMap<>();

    unknownCompactionServiceErrorCache =
        CacheBuilder.newBuilder().expireAfterWrite(5, MINUTES).build();

    currentCfg.getPlanners().forEach((serviceName, plannerClassName) -> {
      try {
        tmpServices.put(CompactionServiceId.of(serviceName),
            new CompactionService(serviceName, plannerClassName,
                currentCfg.getRateLimit(serviceName),
                currentCfg.getOptions().getOrDefault(serviceName, Map.of()), context, ceMetrics,
                this::getExternalExecutor));
      } catch (RuntimeException e) {
        log.error("Failed to create compaction service {} with planner:{} options:{}", serviceName,
            plannerClassName, currentCfg.getOptions().getOrDefault(serviceName, Map.of()), e);
      }
    });

    this.services = Map.copyOf(tmpServices);

    this.maxTimeBetweenChecks =
        context.getConfiguration().getTimeInMillis(Property.TSERV_MAJC_DELAY);

    ceMetrics.setExternalMetricsSupplier(this::getExternalMetrics);
  }

  public void compactableChanged(Compactable compactable) {
    compactablesToCheck.add(compactable);
  }

  private synchronized void checkForConfigChanges(boolean force) {
    try {
      final long secondsSinceLastCheck =
          NANOSECONDS.toSeconds(System.nanoTime() - lastConfigCheckTime);
      if (!force && (secondsSinceLastCheck < 1)) {
        return;
      }

      lastConfigCheckTime = System.nanoTime();

      var tmpCfg =
          new CompactionServicesConfig(context.getConfiguration(), this::warnAboutDeprecation);

      if (!currentCfg.equals(tmpCfg)) {
        Map<CompactionServiceId,CompactionService> tmpServices = new HashMap<>();

        tmpCfg.getPlanners().forEach((serviceName, plannerClassName) -> {

          try {
            var csid = CompactionServiceId.of(serviceName);
            var service = services.get(csid);
            if (service == null) {
              tmpServices.put(csid,
                  new CompactionService(serviceName, plannerClassName,
                      tmpCfg.getRateLimit(serviceName),
                      tmpCfg.getOptions().getOrDefault(serviceName, Map.of()), context, ceMetrics,
                      this::getExternalExecutor));
            } else {
              service.configurationChanged(plannerClassName, tmpCfg.getRateLimit(serviceName),
                  tmpCfg.getOptions().getOrDefault(serviceName, Map.of()));
              tmpServices.put(csid, service);
            }
          } catch (RuntimeException e) {
            throw new RuntimeException("Failed to create or update compaction service "
                + serviceName + " with planner:" + plannerClassName + " options:"
                + tmpCfg.getOptions().getOrDefault(serviceName, Map.of()), e);
          }
        });

        var deletedServices = Sets.difference(services.keySet(), tmpServices.keySet());

        for (var dcsid : deletedServices) {
          services.get(dcsid).stop();
        }

        this.currentCfg = tmpCfg;
        this.services = Map.copyOf(tmpServices);

        HashSet<CompactionExecutorId> activeExternalExecs = new HashSet<>();
        services.values().forEach(cs -> cs.getExternalExecutorsInUse(activeExternalExecs::add));
        // clean up an external compactors that are no longer in use by any compaction service
        externalExecutors.keySet().retainAll(activeExternalExecs);

      }
    } catch (RuntimeException e) {
      log.error("Failed to reconfigure compaction services ", e);
    }
  }

  public void start() {
    log.debug("Started compaction manager");
    Threads.createCriticalThread("Compaction Manager", () -> mainLoop()).start();
  }

  public CompactionServices getServices() {
    var serviceIds = services.keySet();

    return new CompactionServices() {
      @Override
      public Set<CompactionServiceId> getIds() {
        return serviceIds;
      }
    };
  }

  public boolean isCompactionQueued(KeyExtent extent, Set<CompactionServiceId> servicesUsed) {
    return servicesUsed.stream().map(services::get).filter(Objects::nonNull)
        .anyMatch(compactionService -> compactionService.isCompactionQueued(extent));
  }

  public int getCompactionsRunning() {
    return services.values().stream().mapToInt(cs -> cs.getCompactionsRunning(CType.INTERNAL)).sum()
        + runningExternalCompactions.size();
  }

  public int getCompactionsQueued() {
    return services.values().stream().mapToInt(cs -> cs.getCompactionsQueued(CType.INTERNAL)).sum()
        + externalExecutors.values().stream()
            .mapToInt(ee -> ee.getCompactionsQueued(CType.EXTERNAL)).sum();
  }

  public ExternalCompactionJob reserveExternalCompaction(String queueName, long priority,
      String compactorId, ExternalCompactionId externalCompactionId) {
    log.debug("Attempting to reserve external compaction, queue:{} priority:{} compactor:{}",
        queueName, priority, compactorId);

    ExternalCompactionExecutor extCE = getExternalExecutor(queueName);
    var ecJob = extCE.reserveExternalCompaction(priority, compactorId, externalCompactionId);
    if (ecJob != null) {
      runningExternalCompactions.put(ecJob.getExternalCompactionId(),
          new ExtCompInfo(ecJob.getExtent(), extCE.getId()));
      log.debug("Reserved external compaction {} {}", ecJob.getExternalCompactionId(),
          ecJob.getExtent());
    }
    return ecJob;
  }

  ExternalCompactionExecutor getExternalExecutor(CompactionExecutorId ceid) {
    return externalExecutors.computeIfAbsent(ceid, id -> new ExternalCompactionExecutor(id));
  }

  ExternalCompactionExecutor getExternalExecutor(String queueName) {
    return getExternalExecutor(CompactionExecutorIdImpl.externalId(queueName));
  }

  public void registerExternalCompaction(ExternalCompactionId ecid, KeyExtent extent,
      CompactionExecutorId ceid) {
    runningExternalCompactions.put(ecid, new ExtCompInfo(extent, ceid));
    log.trace("registered external compaction {} {}", ecid, extent);
  }

  public void commitExternalCompaction(ExternalCompactionId extCompactionId,
      KeyExtent extentCompacted, Map<KeyExtent,Tablet> currentTablets, long fileSize,
      long entries) {
    var ecInfo = runningExternalCompactions.get(extCompactionId);
    if (ecInfo != null) {
      Preconditions.checkState(ecInfo.extent.equals(extentCompacted),
          "Unexpected extent seen on compaction commit %s %s", ecInfo.extent, extentCompacted);
      Tablet tablet = currentTablets.get(ecInfo.extent);
      if (tablet != null) {
        log.debug("Attempting to commit external compaction {} {}", extCompactionId,
            tablet.getExtent());
        tablet.asCompactable().commitExternalCompaction(extCompactionId, fileSize, entries);
        compactablesToCheck.add(tablet.asCompactable());
        runningExternalCompactions.remove(extCompactionId);
        log.trace("Committed external compaction {} {}", extCompactionId, tablet.getExtent());
      } else {
        log.debug("Ignoring request to commit {} {} because its not in set of known tablets",
            extCompactionId, ecInfo.extent);
      }
    } else {
      log.debug("Ignoring request to commit {} because its not in runningExternalCompactions",
          extCompactionId);
    }
  }

  public void externalCompactionFailed(ExternalCompactionId ecid, KeyExtent extentCompacted,
      Map<KeyExtent,Tablet> currentTablets) {
    var ecInfo = runningExternalCompactions.get(ecid);
    if (ecInfo != null) {
      Preconditions.checkState(ecInfo.extent.equals(extentCompacted),
          "Unexpected extent seen on compaction commit %s %s", ecInfo.extent, extentCompacted);
      Tablet tablet = currentTablets.get(ecInfo.extent);
      if (tablet != null) {
        log.debug("Attempting to fail external compaction {} {}", ecid, tablet.getExtent());
        tablet.asCompactable().externalCompactionFailed(ecid);
        compactablesToCheck.add(tablet.asCompactable());
        runningExternalCompactions.remove(ecid);
        log.trace("Failed external compaction {} {}", ecid, tablet.getExtent());
      } else {
        log.debug("Ignoring request to fail {} {} because its not in set of known tablets", ecid,
            ecInfo.extent);
      }
    } else {
      log.debug("Ignoring request to fail {} because its not in runningExternalCompactions", ecid);
    }
  }

  public List<TCompactionQueueSummary> getCompactionQueueSummaries() {
    return externalExecutors.values().stream().flatMap(ece -> ece.summarize())
        .collect(Collectors.toList());
  }

  public static class ExtCompMetric {
    public CompactionExecutorId ceid;
    public int running;
    public int queued;
  }

  public Collection<ExtCompMetric> getExternalMetrics() {
    Map<CompactionExecutorId,ExtCompMetric> metrics = new HashMap<>();

    externalExecutors.forEach((eeid, ece) -> {
      ExtCompMetric ecm = new ExtCompMetric();
      ecm.ceid = eeid;
      ecm.queued = ece.getCompactionsQueued(CType.EXTERNAL);
      metrics.put(eeid, ecm);
    });

    runningExternalCompactions.values().forEach(eci -> {
      var ecm = metrics.computeIfAbsent(eci.executor, id -> {
        var newEcm = new ExtCompMetric();
        newEcm.ceid = id;
        return newEcm;
      });

      ecm.running++;
    });

    return metrics.values();
  }

  public void compactableClosed(KeyExtent extent, Set<CompactionServiceId> servicesUsed,
      Set<ExternalCompactionId> ecids) {
    runningExternalCompactions.keySet().removeAll(ecids);
    if (log.isTraceEnabled()) {
      ecids.forEach(ecid -> log.trace(
          "Removed {} from runningExternalCompactions for {} as part of close", ecid, extent));
    }
    servicesUsed.stream().map(services::get).filter(Objects::nonNull)
        .forEach(compService -> compService.compactableClosed(extent));
  }
}
