/*
 * 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.gobblin.data.management.trash;

import java.io.IOException;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;

import com.google.common.collect.Lists;

import lombok.Data;
import lombok.Getter;


/**
 * Implementation of {@link ProxiedTrash} to use for testing. All operations in this implementation are noop, but user
 * can get all delete operations executed using {@link #getDeleteOperations}. This implementation does not use the
 * file system at all, so user can use a minimally mocked file system.
 *
 * <p>
 *   This class optionally support simulating file system delay with an internal clock. The clock does not advance
 *   by itself, allowing programmers fine testing over a file system with delay.
 * </p>
 */
public class TestTrash extends MockTrash {

  private static final String DELAY_TICKS_KEY = "gobblin.trash.test.delays.ticks";

  /**
   * Creates {@link java.util.Properties} that will generate a {@link org.apache.gobblin.data.management.trash.TestTrash} when
   * using {@link org.apache.gobblin.data.management.trash.TrashFactory}.
   */
  public static Properties propertiesForTestTrash() {
    Properties properties = new Properties();
    properties.setProperty(TrashFactory.TRASH_TEST, Boolean.toString(true));
    return properties;
  }

  /**
   * Mutates properties so that creating a TestTrash with this properties object will simulate delay in the
   * filesystem.
   *
   * <p>
   *   When simulating delay, any operation related to the filesystem will initially block indefinitely. The test
   *   trash uses an internal clock that must be advanced by the user (it does not advance by itself).
   *   Operations are blocked for a specified number of ticks in the clock. To tick, the user must call the
   *   {@link #tick} method.
   * </p>
   *
   * <p>
   *   For example, if delay is 2:
   *   * User calls testTrash.moveToTrash(new Path("/"))  -> call blocks indefinitely, nothing added to delete operations
   *                                                         list.
   *   * User calls testTrash.tick()   -> call still blocked.
   *   * User calls testTrash.tick()   -> moveToTrash call returns, operation added to delete operations list.
   * </p>
   *
   * @param properties {@link Properties} used for building a test trash.
   * @param delay All calls to {@link TestTrash} involving file system will simulate a delay of this many ticks.
   */
  public static void simulateDelay(Properties properties, long delay) {
    properties.setProperty(DELAY_TICKS_KEY, Long.toString(delay));
  }

  /**
   * Abstraction for a delete operation. Stores deleted {@link org.apache.hadoop.fs.Path} and user proxied for the
   * deletion. When calling {@link #moveToTrash}, {@link #user} is set to null.
   */
  @Data
  public static class DeleteOperation {
    private final Path path;
    private final String user;
  }

  @Getter
  private final List<DeleteOperation> deleteOperations;
  private final String user;
  private final long delay;
  private long clockState;
  private final Lock lock;
  private final Condition clockStateUpdated;
  private final Condition signalReceived;

  private final AtomicLong callsReceivedSignal;
  private final AtomicLong operationsWaiting;
  private final AtomicLong operationsReceived;

  @Getter
  private final boolean simulate;
  @Getter
  private final boolean skipTrash;

  public TestTrash(FileSystem fs, Properties props, String user) throws IOException {
    super(fs, propertiesForConstruction(props), user);
    this.user = user;
    this.deleteOperations = Lists.newArrayList();
    this.simulate =
        props.containsKey(TrashFactory.SIMULATE) && Boolean.parseBoolean(props.getProperty(TrashFactory.SIMULATE));
    this.skipTrash =
        props.containsKey(TrashFactory.SKIP_TRASH) && Boolean.parseBoolean(props.getProperty(TrashFactory.SKIP_TRASH));

    this.operationsReceived = new AtomicLong();

    this.lock = new ReentrantLock();
    this.clockStateUpdated = this.lock.newCondition();
    this.signalReceived = this.lock.newCondition();
    this.clockState = 0;
    this.operationsWaiting = new AtomicLong();
    this.callsReceivedSignal = new AtomicLong();
    if (props.containsKey(DELAY_TICKS_KEY)) {
      this.delay = Long.parseLong(props.getProperty(DELAY_TICKS_KEY));
    } else {
      this.delay = 0;
    }
  }

  @Override
  public boolean moveToTrash(Path path) throws IOException {
    this.operationsReceived.incrementAndGet();
    addDeleteOperation(new DeleteOperation(path, null));
    return true;
  }

  @Override
  public boolean moveToTrashAsUser(Path path, String user) throws IOException {
    this.operationsReceived.incrementAndGet();
    addDeleteOperation(new DeleteOperation(path, user));
    return true;
  }

  @Override
  public boolean moveToTrashAsOwner(Path path) throws IOException {
    return moveToTrashAsUser(path, this.user);
  }

  public long getOperationsReceived() {
    return this.operationsReceived.get();
  }

  public long getOperationsWaiting() {
    return this.operationsWaiting.get();
  }

  /**
   * Advance the internal clock by one tick. The call will block until all appropriate threads finish adding their
   * {@link DeleteOperation}s to the list.
   */
  public void tick() {

    this.lock.lock();

    try {
      // Advance clock
      this.clockState++;

      // Acquire lock, register how many threads are waiting for signal
      long callsAwaitingSignalOld = this.operationsWaiting.get();
      this.callsReceivedSignal.set(0);
      this.operationsWaiting.set(0);

      // Send signal
      this.clockStateUpdated.signalAll();

      while (this.callsReceivedSignal.get() < callsAwaitingSignalOld) {
        // this will release the lock, and it will periodically compare the number of threads that were awaiting
        // signal against the number of threads that have already received the signal. Therefore, this statement
        // will block until all threads have acked signal.
        this.signalReceived.await();
      }
    } catch (InterruptedException ie) {
      // Interrupted
    } finally {
      this.lock.unlock();
    }

  }

  private void addDeleteOperation(DeleteOperation dop) {

    // Acquire lock
    this.lock.lock();

    // Figure out when the operation can return
    long executeAt = this.clockState + this.delay;
    boolean firstLoop = true;

    try {
      // If delay is 0, this continues immediately.
      while (this.clockState < executeAt) {
        // If this is not the first loop, it means we have received a signal from tick, but still not at
        // appropriate clock state. Ack the receive (this is done here because if it is ready to "delete", it should
        // only ack after actually adding the DeleteOperation to the list).
        if (!firstLoop) {
          this.callsReceivedSignal.incrementAndGet();
          this.signalReceived.signalAll();
        }
        firstLoop = false;
        // Add itself to the list of calls awaiting signal
        this.operationsWaiting.incrementAndGet();
        // Await for signal that the clock has been updated
        this.clockStateUpdated.await();
      }
      // Perform "delete" operation, i.e. add DeleteOperation to list
      this.deleteOperations.add(dop);

      // Ack receipt of signal
      this.callsReceivedSignal.incrementAndGet();
      this.signalReceived.signal();
    } catch (InterruptedException ie) {
      // Interrupted
    } finally {
      this.lock.unlock();
    }
  }

  private static Properties propertiesForConstruction(Properties properties) {
    Properties newProperties = new Properties();
    newProperties.putAll(properties);
    newProperties.setProperty(Trash.SNAPSHOT_CLEANUP_POLICY_CLASS_KEY,
        NoopSnapshotCleanupPolicy.class.getCanonicalName());
    newProperties.setProperty(Trash.TRASH_LOCATION_KEY, "/test/path");
    return newProperties;
  }

}
