/*
 * 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.beam.runners.spark.stateful;

import com.google.common.base.Stopwatch;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import java.io.Closeable;
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
import org.apache.beam.runners.core.metrics.MetricsContainerStepMap;
import org.apache.beam.runners.spark.coders.CoderHelpers;
import org.apache.beam.runners.spark.io.EmptyCheckpointMark;
import org.apache.beam.runners.spark.io.MicrobatchSource;
import org.apache.beam.runners.spark.io.SparkUnboundedSource.Metadata;
import org.apache.beam.sdk.coders.Coder;
import org.apache.beam.sdk.io.Source;
import org.apache.beam.sdk.io.UnboundedSource;
import org.apache.beam.sdk.metrics.MetricsContainer;
import org.apache.beam.sdk.metrics.MetricsEnvironment;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
import org.apache.beam.sdk.transforms.windowing.PaneInfo;
import org.apache.beam.sdk.util.WindowedValue;
import org.apache.spark.streaming.State;
import org.apache.spark.streaming.StateSpec;
import org.joda.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import scala.Option;
import scala.Tuple2;
import scala.runtime.AbstractFunction3;

/** A class containing {@link org.apache.spark.streaming.StateSpec} mappingFunctions. */
public class StateSpecFunctions {
  private static final Logger LOG = LoggerFactory.getLogger(StateSpecFunctions.class);

  /** A helper class that is essentially a {@link Serializable} {@link AbstractFunction3}. */
  private abstract static class SerializableFunction3<T1, T2, T3, T4>
      extends AbstractFunction3<T1, T2, T3, T4> implements Serializable {}

  /**
   * A {@link org.apache.spark.streaming.StateSpec} function to support reading from an {@link
   * UnboundedSource}.
   *
   * <p>This StateSpec function expects the following:
   *
   * <ul>
   *   <li>Key: The (partitioned) Source to read from.
   *   <li>Value: An optional {@link UnboundedSource.CheckpointMark} to start from.
   *   <li>State: A byte representation of the (previously) persisted CheckpointMark.
   * </ul>
   *
   * And returns an iterator over all read values (for the micro-batch).
   *
   * <p>This stateful operation could be described as a flatMap over a single-element stream, which
   * outputs all the elements read from the {@link UnboundedSource} for this micro-batch. Since
   * micro-batches are bounded, the provided UnboundedSource is wrapped by a {@link
   * MicrobatchSource} that applies bounds in the form of duration and max records (per
   * micro-batch).
   *
   * <p>In order to avoid using Spark Guava's classes which pollute the classpath, we use the {@link
   * StateSpec#function(scala.Function3)} signature which employs scala's native {@link
   * scala.Option}, instead of the {@link
   * StateSpec#function(org.apache.spark.api.java.function.Function3)} signature, which employs
   * Guava's {@link com.google.common.base.Optional}.
   *
   * <p>See also <a href="https://issues.apache.org/jira/browse/SPARK-4819">SPARK-4819</a>.
   *
   * @param options A serializable {@link SerializablePipelineOptions}.
   * @param <T> The type of the input stream elements.
   * @param <CheckpointMarkT> The type of the {@link UnboundedSource.CheckpointMark}.
   * @return The appropriate {@link org.apache.spark.streaming.StateSpec} function.
   */
  public static <T, CheckpointMarkT extends UnboundedSource.CheckpointMark>
      scala.Function3<
              Source<T>, Option<CheckpointMarkT>, State<Tuple2<byte[], Instant>>,
              Tuple2<Iterable<byte[]>, Metadata>>
          mapSourceFunction(final SerializablePipelineOptions options, final String stepName) {

    return new SerializableFunction3<
        Source<T>, Option<CheckpointMarkT>, State<Tuple2<byte[], Instant>>,
        Tuple2<Iterable<byte[]>, Metadata>>() {

      @Override
      public Tuple2<Iterable<byte[]>, Metadata> apply(
          Source<T> source,
          Option<CheckpointMarkT> startCheckpointMark,
          State<Tuple2<byte[], Instant>> state) {

        MetricsContainerStepMap metricsContainers = new MetricsContainerStepMap();
        MetricsContainer metricsContainer = metricsContainers.getContainer(stepName);

        // Add metrics container to the scope of org.apache.beam.sdk.io.Source.Reader methods
        // since they may report metrics.
        try (Closeable ignored = MetricsEnvironment.scopedMetricsContainer(metricsContainer)) {
          // source as MicrobatchSource
          MicrobatchSource<T, CheckpointMarkT> microbatchSource =
              (MicrobatchSource<T, CheckpointMarkT>) source;

          // Initial high/low watermarks.
          Instant lowWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
          final Instant highWatermark;

          // if state exists, use it, otherwise it's first time so use the startCheckpointMark.
          // startCheckpointMark may be EmptyCheckpointMark (the Spark Java API tries to apply
          // Optional(null)), which is handled by the UnboundedSource implementation.
          Coder<CheckpointMarkT> checkpointCoder = microbatchSource.getCheckpointMarkCoder();
          CheckpointMarkT checkpointMark;
          if (state.exists()) {
            // previous (output) watermark is now the low watermark.
            lowWatermark = state.get()._2();
            checkpointMark = CoderHelpers.fromByteArray(state.get()._1(), checkpointCoder);
            LOG.info("Continue reading from an existing CheckpointMark.");
          } else if (startCheckpointMark.isDefined()
              && !startCheckpointMark.get().equals(EmptyCheckpointMark.get())) {
            checkpointMark = startCheckpointMark.get();
            LOG.info("Start reading from a provided CheckpointMark.");
          } else {
            checkpointMark = null;
            LOG.info("No CheckpointMark provided, start reading from default.");
          }

          // create reader.
          final MicrobatchSource.Reader /*<T>*/ microbatchReader;
          final Stopwatch stopwatch = Stopwatch.createStarted();
          long readDurationMillis = 0;

          try {
            microbatchReader =
                (MicrobatchSource.Reader)
                    microbatchSource.getOrCreateReader(options.get(), checkpointMark);
          } catch (IOException e) {
            throw new RuntimeException(e);
          }

          // read microbatch as a serialized collection.
          final List<byte[]> readValues = new ArrayList<>();
          WindowedValue.FullWindowedValueCoder<T> coder =
              WindowedValue.FullWindowedValueCoder.of(
                  source.getOutputCoder(), GlobalWindow.Coder.INSTANCE);
          try {
            // measure how long a read takes per-partition.
            boolean finished = !microbatchReader.start();
            while (!finished) {
              final WindowedValue<T> wv =
                  WindowedValue.of(
                      (T) microbatchReader.getCurrent(),
                      microbatchReader.getCurrentTimestamp(),
                      GlobalWindow.INSTANCE,
                      PaneInfo.NO_FIRING);
              readValues.add(CoderHelpers.toByteArray(wv, coder));
              finished = !microbatchReader.advance();
            }

            // end-of-read watermark is the high watermark, but don't allow decrease.
            final Instant sourceWatermark = microbatchReader.getWatermark();
            highWatermark = sourceWatermark.isAfter(lowWatermark) ? sourceWatermark : lowWatermark;

            readDurationMillis = stopwatch.stop().elapsed(TimeUnit.MILLISECONDS);

            LOG.info(
                "Source id {} spent {} millis on reading.",
                microbatchSource.getId(),
                readDurationMillis);

            // if the Source does not supply a CheckpointMark skip updating the state.
            @SuppressWarnings("unchecked")
            final CheckpointMarkT finishedReadCheckpointMark =
                (CheckpointMarkT) microbatchReader.getCheckpointMark();
            byte[] codedCheckpoint =
                CoderHelpers.toByteArray(finishedReadCheckpointMark, checkpointCoder);

            // persist the end-of-read (high) watermark for following read, where it will become
            // the next low watermark.
            state.update(new Tuple2<>(codedCheckpoint, highWatermark));
          } catch (IOException e) {
            throw new RuntimeException("Failed to read from reader.", e);
          }

          final ArrayList<byte[]> payload =
              Lists.newArrayList(Iterators.unmodifiableIterator(readValues.iterator()));

          return new Tuple2<>(
              (Iterable<byte[]>) payload,
              new Metadata(
                  readValues.size(),
                  lowWatermark,
                  highWatermark,
                  readDurationMillis,
                  metricsContainers));

        } catch (IOException e) {
          throw new RuntimeException(e);
        }
      }
    };
  }
}
