/*
 * 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.solr.common.util;

import java.lang.invoke.MethodHandles;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Source of time.
 * <p>NOTE: depending on implementation returned values may not be related in any way to the
 * current Epoch or calendar time, and they may even be negative - but the API guarantees that
 * they are always monotonically increasing.</p>
 */
public abstract class TimeSource {
  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  /**
   * Implementation that uses {@link System#currentTimeMillis()}.
   * This implementation's {@link #getTimeNs()} returns the same values as
   * {@link #getEpochTimeNs()}.
   */
  public static final class CurrentTimeSource extends TimeSource {

    @Override
    @SuppressForbidden(reason = "Needed to provide timestamps based on currentTimeMillis.")
    public long getTimeNs() {
      return TimeUnit.NANOSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    @Override
    public long getEpochTimeNs() {
      return getTimeNs();
    }

    @Override
    public long[] getTimeAndEpochNs() {
      long time = getTimeNs();
      return new long[] {time, time};
    }

    @Override
    public void sleep(long ms) throws InterruptedException {
      Thread.sleep(ms);
    }

    @Override
    public long convertDelay(TimeUnit fromUnit, long value, TimeUnit toUnit) {
      return toUnit.convert(value, fromUnit);
    }
  }

  /**
   * Implementation that uses {@link System#nanoTime()}.
   * Epoch time is initialized using {@link CurrentTimeSource}, and then
   * calculated as the elapsed number of nanoseconds as measured by this
   * implementation.
   */
  public static final class NanoTimeSource extends TimeSource {
    private final long epochStart;
    private final long nanoStart;

    public NanoTimeSource() {
      epochStart = CURRENT_TIME.getTimeNs();
      nanoStart = System.nanoTime();
    }

    @Override
    public long getTimeNs() {
      return System.nanoTime();
    }

    @Override
    public long getEpochTimeNs() {
      return epochStart + getTimeNs() - nanoStart;
    }

    @Override
    public long[] getTimeAndEpochNs() {
      long time = getTimeNs();
      return new long[] {time, epochStart + time - nanoStart};
    }

    @Override
    public void sleep(long ms) throws InterruptedException {
      Thread.sleep(ms);
    }

    @Override
    public long convertDelay(TimeUnit fromUnit, long value, TimeUnit toUnit) {
      return toUnit.convert(value, fromUnit);
    }
  }

  /** Implementation that uses {@link #NANO_TIME} accelerated by a double multiplier. */
  public static final class SimTimeSource extends TimeSource {

    final double multiplier;
    final long nanoStart;
    final long epochStart;

    /**
     * Create a simulated time source that runs faster than real time by a multiplier.
     * @param multiplier must be greater than 0.0
     */
    public SimTimeSource(double multiplier) {
      this.multiplier = multiplier;
      epochStart = CURRENT_TIME.getTimeNs();
      nanoStart = NANO_TIME.getTimeNs();
    }

    @Override
    public long getTimeNs() {
      return nanoStart + Math.round((double)(NANO_TIME.getTimeNs() - nanoStart) * multiplier);
    }

    @Override
    public long getEpochTimeNs() {
      return epochStart + getTimeNs() - nanoStart;
    }

    @Override
    public long[] getTimeAndEpochNs() {
      long time = getTimeNs();
      return new long[] {time, epochStart + time - nanoStart};
    }

    @Override
    public void sleep(long ms) throws InterruptedException {
      ms = Math.round((double)ms / multiplier);
      Thread.sleep(ms);
    }

    @Override
    public long convertDelay(TimeUnit fromUnit, long value, TimeUnit toUnit) {
      long nano = Math.round((double)TimeUnit.NANOSECONDS.convert(value, fromUnit) / multiplier);
      return toUnit.convert(nano, TimeUnit.NANOSECONDS);
    }

    @Override
    public String toString() {
      return super.toString() + ":" + multiplier;
    }
  }

  /** This instance uses {@link CurrentTimeSource} for generating timestamps. */
  public static final TimeSource CURRENT_TIME = new CurrentTimeSource();

  /** This instance uses {@link NanoTimeSource} for generating timestamps. */
  public static final TimeSource NANO_TIME = new NanoTimeSource();

  private static Map<String, SimTimeSource> simTimeSources = new ConcurrentHashMap<>();

  /**
   * Obtain an instance of time source.
   * @param type supported types: <code>currentTime</code>, <code>nanoTime</code> and accelerated
   *             time with a double factor in the form of <code>simTime:FACTOR</code>, eg.
   *             <code>simTime:2.5</code>
   * @return one of the supported types
   */
  public static TimeSource get(String type) {
    if (type == null) {
      return NANO_TIME;
    } else if (type.equals("currentTime")) {
      return CURRENT_TIME;
    } else if (type.equals("nanoTime")) {
      return NANO_TIME;
    } else if (type.startsWith("simTime")) {
      return simTimeSources.computeIfAbsent(type, t -> {
        String[] parts = t.split(":");
        double mul = 1.0;
        if (parts.length != 2) {
          log.warn("Invalid simTime specification, assuming multiplier==1.0: '" + type + "'");
        } else {
          try {
            mul = Double.parseDouble(parts[1]);
          } catch (Exception e) {
            log.warn("Invalid simTime specification, assuming multiplier==1.0: '" + type + "'");
          }
        }
        return new SimTimeSource(mul);
      });
    } else {
      throw new UnsupportedOperationException("Unsupported time source type '" + type + "'.");
    }
  }

  /**
   * Return a time value, in nanosecond units. Depending on implementation this value may or
   * may not be related to Epoch time.
   */
  public abstract long getTimeNs();

  /**
   * Return Epoch time. Implementations that are not natively based on epoch time may
   * return values that are consistently off by a (small) fixed number of milliseconds from
   * the actual epoch time.
   */
  public abstract long getEpochTimeNs();

  /**
   * Return both the source's time value and the corresponding epoch time
   * value. This method ensures that epoch time calculations use the same internal
   * value of time as that reported by {@link #getTimeNs()}.
   * @return an array where the first element is {@link #getTimeNs()} and the
   * second element is {@link #getEpochTimeNs()}.
   */
  public abstract long[] getTimeAndEpochNs();

  /**
   * Sleep according to this source's notion of time. Eg. accelerated time source such as
   * {@link SimTimeSource} will sleep proportionally shorter, according to its multiplier.
   * @param ms number of milliseconds to sleep
   * @throws InterruptedException when the current thread is interrupted
   */
  public abstract void sleep(long ms) throws InterruptedException;

  /**
   * This method allows using TimeSource with APIs that require providing just plain time intervals,
   * eg. {@link Object#wait(long)}. Values returned by this method are adjusted according to the
   * time source's notion of time - eg. accelerated time source provided by {@link SimTimeSource}
   * will return intervals that are proportionally shortened by the multiplier.
   * <p>NOTE: converting small values may significantly affect precision of the returned values
   * due to rounding, especially for accelerated time source, so care should be taken to use time
   * units that result in relatively large values. For example, converting a value of 1 expressed
   * in seconds would result in less precision than converting a value of 1000 expressed in milliseconds.</p>
   * @param fromUnit source unit
   * @param value original value
   * @param toUnit target unit
   * @return converted value, possibly scaled by the source's notion of accelerated time
   * (see {@link SimTimeSource})
   */
  public abstract long convertDelay(TimeUnit fromUnit, long value, TimeUnit toUnit);

  public String toString() {
    return getClass().getSimpleName();
  }
}
