// Copyright 2024 The Bazel Authors. All rights reserved.
//
// Licensed 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 com.google.devtools.build.lib.util;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.google.devtools.build.lib.clock.Clock;
import com.google.errorprone.annotations.ThreadSafe;
import com.google.errorprone.annotations.concurrent.GuardedBy;
import java.time.Instant;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.Nullable;

/**
 * A dispenser for up to 'total' simultaneous units of some resource. The resource itself is not
 * accessed through this object; this is basically an asynchronous interface to a counting
 * semaphore.
 */
@ThreadSafe
public final class ConcurrencyMeter {

  private final String name;
  private final long total;

  @GuardedBy("this")
  private final Clock clock;

  @GuardedBy("this")
  private long leased = 0;

  @GuardedBy("this")
  private final Queue<PendingJob> queue = new PriorityQueue<>();

  @GuardedBy("this")
  private long maxLeased = 0;

  @GuardedBy("this")
  private Instant maxLeasedTimestamp;

  /**
   * Creates a meter with the given number of units.
   *
   * @param name an identifier for this meter, for use in {@link #getStats}
   * @param total total number of permits that may be dispensed
   * @param clock provides the current time for {@link Stats#maxLeasedTimeMs()}
   */
  public ConcurrencyMeter(String name, long total, Clock clock) {
    this.name = checkNotNull(name);
    this.total = total;
    this.clock = checkNotNull(clock);
  }

  @VisibleForTesting
  synchronized int queueSize() {
    return queue.size();
  }

  public ListenableFuture<Ticket> request(long quantity, long priority) {
    return request(quantity, priority, () -> {});
  }

  /**
   * Enqueues a request for {@code quantity} units of the resource managed by this meter. When the
   * request is filled, the result becomes available.
   *
   * <p>The resource must be released either by cancelling the future or by calling {@link
   * Ticket#done} on the ticket after the future completes.
   *
   * @param quantity number of units of resources to acquire
   * @param priority requests with greater priority complete earlier
   * @param ifQueued a callback to be executed if the request is queued
   * @return a future which grants resources only when it completes successfully
   */
  public ListenableFuture<Ticket> request(long quantity, long priority, Runnable ifQueued) {
    checkArgument(quantity >= 0);
    PendingJob job = new PendingJob(quantity, priority);
    ReleasingTicket ticket = maybeLease(job);
    if (ticket != null) {
      setTicket(job, ticket);
    } else {
      ifQueued.run();
      synchronized (this) {
        queue.add(job);
      }
    }
    schedule();
    return job.futureTicket;
  }

  @Nullable
  private synchronized ReleasingTicket maybeLease(PendingJob job) {
    if (leased + job.quantity > total && leased > 0) {
      return null;
    }

    leased += job.quantity;

    if (leased >= maxLeased) {
      maxLeased = leased;
      maxLeasedTimestamp = clock.now();
    }

    return new ReleasingTicket(job.quantity);
  }

  private void setTicket(PendingJob job, ReleasingTicket ticket) {
    if (!job.futureTicket.set(ticket)) {
      // The future may have been cancelled. Release immediately. If the build was interrupted, we
      // may encounter a long chain of cancelled tickets - avoid calling ticket.done() or
      // releaseAndSchedule() which would process them recursively.
      release(job.quantity);
    }
  }

  /** Statistics about a ConcurrencyMeter. */
  public record Stats(String name, long total, long leased, long maxLeased, long maxLeasedTimeMs) {}

  public synchronized Stats getStats() {
    return new Stats(
        name, total, leased, maxLeased, maxLeased > 0 ? maxLeasedTimestamp.toEpochMilli() : 0);
  }

  private synchronized void release(long quantity) {
    checkState(leased >= quantity, "quantity (%s) > leased (%s)", quantity, leased);
    leased -= quantity;
  }

  private void releaseAndSchedule(long quantity) {
    release(quantity);
    schedule();
  }

  private void schedule() {
    while (true) {
      PendingJob job;
      ReleasingTicket ticket;
      synchronized (this) {
        job = queue.peek();
        if (job == null || (ticket = maybeLease(job)) == null) {
          return;
        }
        queue.remove();
      }

      // Set the future outside synchronized block to avoid holding the lock when executing future's
      // callbacks which may hold other locks and call into ConcurrencyMeter causing deadlocks.
      // See: b/319411390
      setTicket(job, ticket);
    }
  }

  private final class ReleasingTicket implements Ticket {
    private final long quantity;
    private final AtomicBoolean released = new AtomicBoolean(false);

    ReleasingTicket(long quantity) {
      this.quantity = quantity;
    }

    @Override
    public void done() {
      boolean alreadyReleased = released.getAndSet(true);
      checkState(!alreadyReleased, "Already released %s units", quantity);
      releaseAndSchedule(quantity);
    }
  }

  private static final class PendingJob implements Comparable<PendingJob> {
    private final SettableFuture<Ticket> futureTicket = SettableFuture.create();
    private final long quantity;
    private final long priority;

    PendingJob(long quantity, long priority) {
      this.quantity = quantity;
      this.priority = priority;
    }

    @Override
    public int compareTo(PendingJob o) {
      return Long.compare(o.priority, priority);
    }
  }

  /** A ticket denoting resource acquisition. */
  public interface Ticket {
    /** Releases the associated resources. Must be called exactly once. */
    void done();
  }
}
