/*
 * 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.translation;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.Nullable;
import org.apache.beam.runners.core.SideInputReader;
import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
import org.apache.beam.runners.spark.util.SideInputBroadcast;
import org.apache.beam.runners.spark.util.SparkSideInputReader;
import org.apache.beam.sdk.coders.Coder;
import org.apache.beam.sdk.coders.CoderException;
import org.apache.beam.sdk.coders.IterableCoder;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.transforms.CombineWithContext;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
import org.apache.beam.sdk.transforms.windowing.PaneInfo;
import org.apache.beam.sdk.transforms.windowing.TimestampCombiner;
import org.apache.beam.sdk.transforms.windowing.WindowFn;
import org.apache.beam.sdk.util.UserCodeException;
import org.apache.beam.sdk.util.WindowedValue;
import org.apache.beam.sdk.values.KV;
import org.apache.beam.sdk.values.PCollectionView;
import org.apache.beam.sdk.values.TupleTag;
import org.apache.beam.sdk.values.TypeDescriptor;
import org.apache.beam.sdk.values.WindowingStrategy;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.annotations.VisibleForTesting;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Preconditions;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.Iterables;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.Lists;
import org.apache.spark.api.java.function.Function;
import org.joda.time.Instant;

/**
 * A {@link org.apache.beam.sdk.transforms.CombineFnBase.GlobalCombineFn} with a {@link
 * org.apache.beam.sdk.transforms.CombineWithContext.Context} for the SparkRunner.
 */
public class SparkCombineFn<InputT, ValueT, AccumT, OutputT> implements Serializable {

  /** Accumulator of WindowedValues holding values for different windows. */
  public interface WindowedAccumulator<
      InputT, ValueT, AccumT, ImplT extends WindowedAccumulator<InputT, ValueT, AccumT, ImplT>> {

    /**
     * Type of the accumulator. The type depends (mostly) on {@link WindowingStrategy}, and more
     * specialized versions enable more optimizations.
     */
    enum Type {
      MERGING,
      NON_MERGING,
      EXPLODE_WINDOWS,
      SINGLE_WINDOW;

      boolean isMapBased() {
        return this == NON_MERGING || this == MERGING;
      }
    }

    /**
     * Check if this accumulator is empty.
     *
     * @return {@code true} if this accumulator is empty
     */
    boolean isEmpty();

    /** Add value with unexploded windows into the accumulator. */
    void add(WindowedValue<InputT> value, SparkCombineFn<InputT, ValueT, AccumT, ?> context)
        throws Exception;

    /**
     * Merge other acccumulator into this one.
     *
     * @param other the other accumulator to merge
     */
    void merge(ImplT other, SparkCombineFn<?, ?, AccumT, ?> context) throws Exception;

    /** Extract output. */
    Collection<WindowedValue<AccumT>> extractOutput();

    /** Create concrete accumulator for given type. */
    static <InputT, ValueT, AccumT> WindowedAccumulator<InputT, ValueT, AccumT, ?> create(
        SparkCombineFn<InputT, ValueT, AccumT, ?> context,
        Function<InputT, ValueT> toValue,
        WindowingStrategy<?, ?> windowingStrategy,
        Comparator<BoundedWindow> windowComparator) {
      return create(toValue, context.getType(windowingStrategy), windowComparator);
    }

    static <InputT, ValueT, AccumT> WindowedAccumulator<InputT, ValueT, AccumT, ?> create(
        Function<InputT, ValueT> toValue, Type type, Comparator<BoundedWindow> windowComparator) {
      switch (type) {
        case MERGING:
          return MergingWindowedAccumulator.create(toValue, windowComparator);
        case NON_MERGING:
          return NonMergingWindowedAccumulator.create(toValue);
        case SINGLE_WINDOW:
        case EXPLODE_WINDOWS:
          return SingleWindowWindowedAccumulator.create(toValue);
        default:
          throw new IllegalArgumentException("Unknown type: " + type);
      }
    }

    /** Create concrete accumulator for given type. */
    static <InputT, ValueT, AccumT> WindowedAccumulator<InputT, ValueT, AccumT, ?> create(
        Function<InputT, ValueT> toValue,
        Type type,
        Iterable<WindowedValue<AccumT>> values,
        Comparator<BoundedWindow> windowComparator) {
      switch (type) {
        case MERGING:
          return MergingWindowedAccumulator.from(toValue, values, windowComparator);
        case NON_MERGING:
          return NonMergingWindowedAccumulator.from(toValue, values);
        case SINGLE_WINDOW:
        case EXPLODE_WINDOWS:
          Iterator<WindowedValue<AccumT>> iter = values.iterator();
          if (iter.hasNext()) {
            return SingleWindowWindowedAccumulator.create(toValue, iter.next());
          }
          return SingleWindowWindowedAccumulator.create(toValue);
        default:
          throw new IllegalArgumentException("Unknown type: " + type);
      }
    }
  }

  /**
   * Accumulator for {@link WindowFn WindowFns} which create only single window per key, and
   * therefore don't need any map to hold different windows per key.
   *
   * @param <AccumT> type of accumulator
   */
  static class SingleWindowWindowedAccumulator<InputT, ValueT, AccumT>
      implements WindowedAccumulator<
          InputT, ValueT, AccumT, SingleWindowWindowedAccumulator<InputT, ValueT, AccumT>> {

    static <InputT, ValueT, AccumT> SingleWindowWindowedAccumulator<InputT, ValueT, AccumT> create(
        Function<InputT, ValueT> toValue) {
      return new SingleWindowWindowedAccumulator<>(toValue);
    }

    static <InputT, ValueT, AccumT> WindowedAccumulator<InputT, ValueT, AccumT, ?> create(
        Function<InputT, ValueT> toValue, WindowedValue<AccumT> accumulator) {
      return new SingleWindowWindowedAccumulator<>(toValue, accumulator);
    }

    final Function<InputT, ValueT> toValue;
    AccumT windowAccumulator = null;
    Instant accTimestamp = null;
    BoundedWindow accWindow = null;

    SingleWindowWindowedAccumulator(Function<InputT, ValueT> toValue) {
      this.toValue = toValue;
    }

    SingleWindowWindowedAccumulator(
        Function<InputT, ValueT> toValue, WindowedValue<AccumT> accumulator) {
      this.toValue = toValue;
      this.windowAccumulator = accumulator.getValue();
      this.accTimestamp =
          accumulator.getTimestamp().equals(BoundedWindow.TIMESTAMP_MIN_VALUE)
              ? null
              : accumulator.getTimestamp();
      this.accWindow = getWindow(accumulator);
    }

    @Override
    public void add(WindowedValue<InputT> value, SparkCombineFn<InputT, ValueT, AccumT, ?> context)
        throws Exception {
      BoundedWindow window = getWindow(value);
      SparkCombineContext ctx = context.ctxtForValue(value);
      TimestampCombiner combiner = context.windowingStrategy.getTimestampCombiner();
      Instant windowTimestamp =
          combiner.assign(
              window,
              context.windowingStrategy.getWindowFn().getOutputTime(value.getTimestamp(), window));
      final AccumT acc;
      final Instant timestamp;
      if (windowAccumulator == null) {
        acc = context.combineFn.createAccumulator(ctx);
        timestamp = windowTimestamp;
      } else {
        acc = windowAccumulator;
        timestamp = accTimestamp;
      }
      AccumT result = context.combineFn.addInput(acc, toValue(value), ctx);
      Instant timestampCombined = combiner.combine(windowTimestamp, timestamp);
      windowAccumulator = result;
      accTimestamp = timestampCombined;
      accWindow = window;
    }

    @Override
    public void merge(
        SingleWindowWindowedAccumulator<InputT, ValueT, AccumT> other,
        SparkCombineFn<?, ?, AccumT, ?> context) {
      if (windowAccumulator != null && other.windowAccumulator != null) {
        List<AccumT> accumulators = Arrays.asList(windowAccumulator, other.windowAccumulator);
        AccumT merged =
            context.combineFn.mergeAccumulators(
                accumulators, context.ctxtForWindows(Arrays.asList(accWindow)));
        Instant combined =
            context
                .windowingStrategy
                .getTimestampCombiner()
                .combine(accTimestamp, other.accTimestamp);
        windowAccumulator = merged;
        accTimestamp = combined;
      } else if (windowAccumulator == null) {
        windowAccumulator = other.windowAccumulator;
        accTimestamp = other.accTimestamp;
        accWindow = other.accWindow;
      }
    }

    @Override
    public Collection<WindowedValue<AccumT>> extractOutput() {
      if (windowAccumulator != null) {
        return Arrays.asList(
            WindowedValue.of(
                windowAccumulator, accTimestamp, accWindow, PaneInfo.ON_TIME_AND_ONLY_FIRING));
      }
      return Collections.emptyList();
    }

    private ValueT toValue(WindowedValue<InputT> input) {
      try {
        return toValue.call(input.getValue());
      } catch (Exception ex) {
        throw UserCodeException.wrap(ex);
      }
    }

    @Override
    public boolean isEmpty() {
      return windowAccumulator == null;
    }
  }

  /**
   * Abstract base class for {@link WindowedAccumulator WindowedAccumulators} which hold
   * accumulators in map.
   *
   * @param <AccumT> type of accumulator
   * @param <ImplT> type of final subclass
   */
  abstract static class MapBasedWindowedAccumulator<
          InputT,
          ValueT,
          AccumT,
          ImplT extends MapBasedWindowedAccumulator<InputT, ValueT, AccumT, ImplT>>
      implements WindowedAccumulator<InputT, ValueT, AccumT, ImplT> {

    final Function<InputT, ValueT> toValue;
    final Map<BoundedWindow, WindowedValue<AccumT>> map;

    MapBasedWindowedAccumulator(
        Function<InputT, ValueT> toValue, Map<BoundedWindow, WindowedValue<AccumT>> map) {
      this.toValue = toValue;
      this.map = map;
    }

    @Override
    public void add(WindowedValue<InputT> value, SparkCombineFn<InputT, ValueT, AccumT, ?> context)
        throws Exception {
      for (WindowedValue<InputT> v : value.explodeWindows()) {
        SparkCombineContext ctx = context.ctxtForValue(v);
        BoundedWindow window = getWindow(v);
        TimestampCombiner combiner = context.windowingStrategy.getTimestampCombiner();
        Instant windowTimestamp =
            combiner.assign(
                window,
                context.windowingStrategy.getWindowFn().getOutputTime(v.getTimestamp(), window));
        map.compute(
            window,
            (w, windowAccumulator) -> {
              final AccumT acc;
              final Instant timestamp;
              if (windowAccumulator == null) {
                acc = context.combineFn.createAccumulator(ctx);
                timestamp = windowTimestamp;
              } else {
                acc = windowAccumulator.getValue();
                timestamp = windowAccumulator.getTimestamp();
              }
              AccumT result = context.combineFn.addInput(acc, toValue(v), ctx);
              Instant timestampCombined = combiner.combine(windowTimestamp, timestamp);
              return WindowedValue.of(result, timestampCombined, window, PaneInfo.NO_FIRING);
            });
      }
      mergeWindows(context);
    }

    @Override
    public void merge(ImplT other, SparkCombineFn<?, ?, AccumT, ?> context) throws Exception {
      other.map.forEach(
          (window, acc) -> {
            WindowedValue<AccumT> thisAcc = this.map.get(window);
            if (thisAcc == null) {
              // just copy
              this.map.put(window, acc);
            } else {
              // merge
              this.map.put(
                  window,
                  WindowedValue.of(
                      context.combineFn.mergeAccumulators(
                          Lists.newArrayList(thisAcc.getValue(), acc.getValue()),
                          context.ctxtForValue(acc)),
                      context
                          .windowingStrategy
                          .getTimestampCombiner()
                          .combine(acc.getTimestamp(), thisAcc.getTimestamp()),
                      window,
                      PaneInfo.NO_FIRING));
            }
          });
      mergeWindows(context);
    }

    @Override
    public Collection<WindowedValue<AccumT>> extractOutput() {
      return map.values();
    }

    @Override
    public boolean isEmpty() {
      return map.isEmpty();
    }

    void mergeWindows(SparkCombineFn<?, ?, AccumT, ?> fn) throws Exception {}

    private ValueT toValue(WindowedValue<InputT> value) {
      try {
        return toValue.call(value.getValue());
      } catch (Exception ex) {
        throw UserCodeException.wrap(ex);
      }
    }
  }

  /** Accumulator for non-merging windows. */
  static class NonMergingWindowedAccumulator<InputT, ValueT, AccumT>
      extends MapBasedWindowedAccumulator<
          InputT, ValueT, AccumT, NonMergingWindowedAccumulator<InputT, ValueT, AccumT>> {

    static <InputT, ValueT, AccumT> NonMergingWindowedAccumulator<InputT, ValueT, AccumT> create(
        Function<InputT, ValueT> toValue) {
      return new NonMergingWindowedAccumulator<>(toValue);
    }

    static <InputT, ValueT, AccumT> NonMergingWindowedAccumulator<InputT, ValueT, AccumT> from(
        Function<InputT, ValueT> toValue, Iterable<WindowedValue<AccumT>> values) {
      return new NonMergingWindowedAccumulator<>(toValue, values);
    }

    @SuppressWarnings("unchecked")
    private NonMergingWindowedAccumulator(Function<InputT, ValueT> toValue) {
      super(toValue, new HashMap<>());
    }

    private NonMergingWindowedAccumulator(
        Function<InputT, ValueT> toValue, Iterable<WindowedValue<AccumT>> values) {
      super(toValue, asMap(values, new HashMap<>()));
    }
  }

  /** Accumulator for merging windows. */
  static class MergingWindowedAccumulator<InputT, ValueT, AccumT>
      extends MapBasedWindowedAccumulator<
          InputT, ValueT, AccumT, MergingWindowedAccumulator<InputT, ValueT, AccumT>> {

    static <InputT, ValueT, AccumT> MergingWindowedAccumulator<InputT, ValueT, AccumT> create(
        Function<InputT, ValueT> toValue, Comparator<BoundedWindow> windowComparator) {
      return new MergingWindowedAccumulator<>(toValue, windowComparator);
    }

    static <InputT, ValueT, AccumT> MergingWindowedAccumulator<InputT, ValueT, AccumT> from(
        Function<InputT, ValueT> toValue,
        Iterable<WindowedValue<AccumT>> values,
        Comparator<BoundedWindow> windowComparator) {
      return new MergingWindowedAccumulator<>(toValue, values, windowComparator);
    }

    @SuppressWarnings("unchecked")
    private MergingWindowedAccumulator(
        Function<InputT, ValueT> toValue, Comparator<BoundedWindow> windowComparator) {
      super(toValue, new TreeMap<>(windowComparator));
    }

    private MergingWindowedAccumulator(
        Function<InputT, ValueT> toValue,
        Iterable<WindowedValue<AccumT>> values,
        Comparator<BoundedWindow> windowComparator) {
      super(toValue, asMap(values, new TreeMap<>(windowComparator)));
    }

    @Override
    void mergeWindows(SparkCombineFn<?, ?, AccumT, ?> fn) throws Exception {

      SparkCombineContext ctx = fn.ctxtForWindows(this.map.keySet());

      @SuppressWarnings("unchecked")
      WindowFn<Object, BoundedWindow> windowFn = (WindowFn) fn.windowingStrategy.getWindowFn();
      windowFn.mergeWindows(
          asMergeContext(
              windowFn,
              (a, b) -> fn.combineFn.mergeAccumulators(Lists.newArrayList(a, b), ctx),
              (toBeMerged, mergeResult) -> {
                Instant mergedInstant =
                    fn.windowingStrategy
                        .getTimestampCombiner()
                        .merge(
                            mergeResult.getKey(),
                            toBeMerged.stream()
                                .map(w -> map.get(w).getTimestamp())
                                .collect(Collectors.toList()));
                toBeMerged.forEach(this.map::remove);
                this.map.put(
                    mergeResult.getKey(),
                    WindowedValue.of(
                        mergeResult.getValue(),
                        mergedInstant,
                        mergeResult.getKey(),
                        PaneInfo.NO_FIRING));
              },
              map));
    }

    private WindowFn<Object, BoundedWindow>.MergeContext asMergeContext(
        WindowFn<Object, BoundedWindow> windowFn,
        BiFunction<AccumT, AccumT, AccumT> mergeFn,
        BiConsumer<Collection<BoundedWindow>, KV<BoundedWindow, AccumT>> afterMerge,
        Map<BoundedWindow, WindowedValue<AccumT>> map) {

      return windowFn.new MergeContext() {

        @Override
        public Collection<BoundedWindow> windows() {
          return map.keySet();
        }

        @Override
        public void merge(Collection<BoundedWindow> toBeMerged, BoundedWindow mergeResult) {
          AccumT accumulator = null;
          for (BoundedWindow w : toBeMerged) {
            WindowedValue<AccumT> windowAccumulator = Objects.requireNonNull(map.get(w));
            if (accumulator == null) {
              accumulator = windowAccumulator.getValue();
            } else {
              accumulator = mergeFn.apply(accumulator, windowAccumulator.getValue());
            }
          }
          afterMerge.accept(toBeMerged, KV.of(mergeResult, accumulator));
        }
      };
    }

    @Override
    public String toString() {
      return "MergingWindowedAccumulator(" + this.map + ")";
    }
  }

  static class WindowedAccumulatorCoder<InputT, ValueT, AccumT>
      extends Coder<WindowedAccumulator<InputT, ValueT, AccumT, ?>> {

    private final Function<InputT, ValueT> toValue;
    private final IterableCoder<WindowedValue<AccumT>> wrap;
    private final Coder<WindowedValue<AccumT>> accumCoder;
    private final Comparator<BoundedWindow> windowComparator;
    private final WindowedAccumulator.Type type;

    @SuppressWarnings("unchecked")
    WindowedAccumulatorCoder(
        Function<InputT, ValueT> toValue,
        Coder<BoundedWindow> windowCoder,
        Comparator<BoundedWindow> windowComparator,
        Coder<AccumT> accumCoder,
        WindowedAccumulator.Type type) {

      this.toValue = toValue;
      this.accumCoder = WindowedValue.FullWindowedValueCoder.of(accumCoder, windowCoder);
      this.windowComparator = windowComparator;
      this.wrap = IterableCoder.of(this.accumCoder);
      this.type = type;
    }

    @Override
    public void encode(WindowedAccumulator<InputT, ValueT, AccumT, ?> value, OutputStream outStream)
        throws CoderException, IOException {
      if (type.isMapBased()) {
        wrap.encode(((MapBasedWindowedAccumulator<?, ?, AccumT, ?>) value).map.values(), outStream);
      } else {
        SingleWindowWindowedAccumulator<?, ?, AccumT> swwa =
            (SingleWindowWindowedAccumulator<?, ?, AccumT>) value;
        if (swwa.isEmpty()) {
          outStream.write(0);
        } else {
          outStream.write(1);
          accumCoder.encode(
              WindowedValue.of(
                  swwa.windowAccumulator, swwa.accTimestamp, swwa.accWindow, PaneInfo.NO_FIRING),
              outStream);
        }
      }
    }

    @Override
    public WindowedAccumulator<InputT, ValueT, AccumT, ?> decode(InputStream inStream)
        throws CoderException, IOException {
      if (type.isMapBased()) {
        return WindowedAccumulator.create(toValue, type, wrap.decode(inStream), windowComparator);
      }
      boolean empty = inStream.read() == 0;
      if (empty) {
        return WindowedAccumulator.create(toValue, type, windowComparator);
      }
      return WindowedAccumulator.create(
          toValue, type, Arrays.asList(accumCoder.decode(inStream)), windowComparator);
    }

    @Override
    public List<? extends Coder<?>> getCoderArguments() {
      return wrap.getComponents();
    }

    @Override
    public void verifyDeterministic() throws NonDeterministicException {}
  }

  /** An implementation of {@link CombineWithContext.Context} for the SparkRunner. */
  static class SparkCombineContext extends CombineWithContext.Context {
    private final PipelineOptions pipelineOptions;
    private final SideInputReader sideInputReader;

    SparkCombineContext(PipelineOptions pipelineOptions, SideInputReader sideInputReader) {
      this.pipelineOptions = pipelineOptions;
      this.sideInputReader = sideInputReader;
    }

    Collection<? extends BoundedWindow> windows = null;

    SparkCombineContext forInput(final Collection<? extends BoundedWindow> windows) {
      this.windows = Objects.requireNonNull(windows);
      return this;
    }

    @Override
    public PipelineOptions getPipelineOptions() {
      return pipelineOptions;
    }

    @Override
    public <T> T sideInput(PCollectionView<T> view) {
      // validate element window.
      Preconditions.checkState(
          windows.size() == 1,
          "sideInput can only be called when the main " + "input element is in exactly one window");
      return sideInputReader.get(view, windows.iterator().next());
    }
  }

  @VisibleForTesting
  static <K, V, AccumT, OutputT> SparkCombineFn<KV<K, V>, V, AccumT, OutputT> keyed(
      CombineWithContext.CombineFnWithContext<V, AccumT, OutputT> combineFn,
      SerializablePipelineOptions options,
      Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs,
      WindowingStrategy<?, ?> windowingStrategy,
      WindowedAccumulator.Type nonMergingStrategy) {
    return new SparkCombineFn<>(
        false, KV::getValue, combineFn, options, sideInputs, windowingStrategy, nonMergingStrategy);
  }

  public static <K, V, AccumT, OutputT> SparkCombineFn<KV<K, V>, V, AccumT, OutputT> keyed(
      CombineWithContext.CombineFnWithContext<V, AccumT, OutputT> combineFn,
      SerializablePipelineOptions options,
      Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs,
      WindowingStrategy<?, ?> windowingStrategy) {
    return new SparkCombineFn<>(
        false, KV::getValue, combineFn, options, sideInputs, windowingStrategy);
  }

  public static <InputT, AccumT, OutputT> SparkCombineFn<InputT, InputT, AccumT, OutputT> globally(
      CombineWithContext.CombineFnWithContext<InputT, AccumT, OutputT> combineFn,
      SerializablePipelineOptions options,
      Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs,
      WindowingStrategy<?, ?> windowingStrategy) {
    return new SparkCombineFn<>(true, e -> e, combineFn, options, sideInputs, windowingStrategy);
  }

  private static <T> Map<BoundedWindow, WindowedValue<T>> asMap(
      Iterable<WindowedValue<T>> values, Map<BoundedWindow, WindowedValue<T>> res) {
    for (WindowedValue<T> v : values) {
      res.put(getWindow(v), v);
    }
    return res;
  }

  /**
   * Create comparator for given type descriptor. Note that the returned {@link Comparator} has to
   * be {@link Serializable}.
   */
  @SuppressWarnings({"unchecked", "rawtypes"})
  private static Comparator<BoundedWindow> asWindowComparator(
      @Nullable TypeDescriptor<?> windowType) {
    final Comparator<BoundedWindow> comparator;
    if (windowType != null
        && StreamSupport.stream(windowType.getInterfaces().spliterator(), false)
            .anyMatch(t -> t.isSubtypeOf(TypeDescriptor.of(Comparable.class)))) {
      comparator = (Comparator) Comparator.naturalOrder();
    } else {
      java.util.function.Function<BoundedWindow, Instant> keyExtractor =
          (java.util.function.Function<BoundedWindow, Instant> & Serializable)
              BoundedWindow::maxTimestamp;
      comparator = Comparator.comparing(keyExtractor);
    }
    return comparator;
  }

  private final boolean globalCombine;
  private final SerializablePipelineOptions options;
  private final Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs;
  final WindowingStrategy<?, BoundedWindow> windowingStrategy;

  private final Function<InputT, ValueT> toValue;
  private final WindowedAccumulator.Type defaultNonMergingCombineStrategy;
  private final CombineWithContext.CombineFnWithContext<ValueT, AccumT, OutputT> combineFn;
  private final Comparator<BoundedWindow> windowComparator;

  // each Spark task should get it's own copy of this SparkCombineFn, and since Spark tasks
  // are single-threaded, it is safe to reuse the context.
  // the combine context is not Serializable so we'll use lazy initialization.
  // ** DO NOT attempt to turn this into a Singleton as Spark may run multiple tasks in parallel
  // in the same JVM (Executor). **
  // ** DO NOT use combineContext directly inside this class, use ctxtForValue instead. **
  private transient SparkCombineContext combineContext;

  SparkCombineFn(
      boolean global,
      Function<InputT, ValueT> toValue,
      CombineWithContext.CombineFnWithContext<ValueT, AccumT, OutputT> combineFn,
      SerializablePipelineOptions options,
      Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs,
      WindowingStrategy<?, ?> windowingStrategy) {
    this(
        global,
        toValue,
        combineFn,
        options,
        sideInputs,
        windowingStrategy,
        WindowedAccumulator.Type.EXPLODE_WINDOWS);
  }

  @VisibleForTesting
  SparkCombineFn(
      boolean global,
      Function<InputT, ValueT> toValue,
      CombineWithContext.CombineFnWithContext<ValueT, AccumT, OutputT> combineFn,
      SerializablePipelineOptions options,
      Map<TupleTag<?>, KV<WindowingStrategy<?, ?>, SideInputBroadcast<?>>> sideInputs,
      WindowingStrategy<?, ?> windowingStrategy,
      WindowedAccumulator.Type defaultNonMergingCombineStrategy) {

    this.globalCombine = global;
    this.options = options;
    this.sideInputs = sideInputs;
    @SuppressWarnings("unchecked")
    WindowingStrategy<?, BoundedWindow> castStrategy = (WindowingStrategy) windowingStrategy;
    this.windowingStrategy = castStrategy;
    this.toValue = toValue;
    this.defaultNonMergingCombineStrategy = defaultNonMergingCombineStrategy;
    this.combineFn = combineFn;
    @SuppressWarnings("unchecked")
    TypeDescriptor<BoundedWindow> untyped =
        (TypeDescriptor<BoundedWindow>) windowingStrategy.getWindowFn().getWindowTypeDescriptor();
    this.windowComparator = asWindowComparator(untyped);
  }

  /** Create empty combiner. Implements Spark's zeroValue for aggregateFn. */
  WindowedAccumulator<InputT, ValueT, AccumT, ?> createCombiner() {
    return WindowedAccumulator.create(this, toValue, windowingStrategy, windowComparator);
  }

  /**
   * Implements Spark's createCombiner function in:
   *
   * <p>{@link org.apache.spark.rdd.PairRDDFunctions#combineByKey}.
   */
  WindowedAccumulator<InputT, ValueT, AccumT, ?> createCombiner(WindowedValue<InputT> value) {
    try {
      WindowedAccumulator<InputT, ValueT, AccumT, ?> accumulator =
          WindowedAccumulator.create(this, toValue, windowingStrategy, windowComparator);
      accumulator.add(value, this);
      return accumulator;
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
  }

  /**
   * Implements Spark's mergeValue function in:
   *
   * <p>{@link org.apache.spark.rdd.PairRDDFunctions#combineByKey}.
   */
  WindowedAccumulator<InputT, ValueT, AccumT, ?> mergeValue(
      WindowedAccumulator<InputT, ValueT, AccumT, ?> accumulator, WindowedValue<InputT> value) {
    try {
      accumulator.add(value, this);
      return accumulator;
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
  }

  /**
   * Implements Spark's mergeCombiners function in:
   *
   * <p>{@link org.apache.spark.rdd.PairRDDFunctions#combineByKey}.
   */
  @SuppressWarnings({"unchecked", "rawtypes"})
  WindowedAccumulator<InputT, ValueT, AccumT, ?> mergeCombiners(
      WindowedAccumulator ac1, WindowedAccumulator ac2) {
    try {
      ac1.merge(ac2, this);
      return ac1;
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
  }

  Iterable<WindowedValue<OutputT>> extractOutput(WindowedAccumulator<?, ?, AccumT, ?> accumulator) {
    return extractOutputStream(accumulator).collect(Collectors.toList());
  }

  /** Extracts the stream of accumulated values. */
  public Stream<WindowedValue<OutputT>> extractOutputStream(
      WindowedAccumulator<?, ?, AccumT, ?> accumulator) {
    return accumulator.extractOutput().stream()
        .filter(Objects::nonNull)
        .map(
            windowAcc ->
                windowAcc.withValue(
                    combineFn.extractOutput(windowAcc.getValue(), ctxtForValue(windowAcc))));
  }

  WindowedAccumulatorCoder<InputT, ValueT, AccumT> accumulatorCoder(
      Coder<BoundedWindow> windowCoder,
      Coder<AccumT> accumulatorCoder,
      WindowingStrategy<?, ?> windowingStrategy) {
    return new WindowedAccumulatorCoder<>(
        toValue, windowCoder, windowComparator, accumulatorCoder, getType(windowingStrategy));
  }

  CombineWithContext.CombineFnWithContext<ValueT, AccumT, OutputT> getCombineFn() {
    return combineFn;
  }

  boolean mustBringWindowToKey() {
    return !getType(windowingStrategy).isMapBased();
  }

  private WindowedAccumulator.Type getType(WindowingStrategy<?, ?> windowingStrategy) {
    if (windowingStrategy.getWindowFn().isNonMerging()) {
      if (globalCombine) {
        /* global combine must use map-based accumulator to incorporate multiple windows */
        return WindowedAccumulator.Type.NON_MERGING;
      }
      if (windowingStrategy.getWindowFn().assignsToOneWindow()
          && GroupNonMergingWindowsFunctions.isEligibleForGroupByWindow(windowingStrategy)) {
        return WindowedAccumulator.Type.SINGLE_WINDOW;
      }
      return defaultNonMergingCombineStrategy;
    }
    return WindowedAccumulator.Type.MERGING;
  }

  private static BoundedWindow getWindow(WindowedValue<?> value) {
    if (value.isSingleWindowedValue()) {
      return ((WindowedValue.SingleWindowedValue) value).getWindow();
    }
    return Iterables.getOnlyElement(value.getWindows());
  }

  @SuppressWarnings("unchecked")
  SparkCombineContext ctxtForValue(WindowedValue<?> input) {
    return ctxtForWindows((Collection) input.getWindows());
  }

  SparkCombineContext ctxtForWindows(Collection<BoundedWindow> windows) {
    if (combineContext == null) {
      combineContext = new SparkCombineContext(options.get(), new SparkSideInputReader(sideInputs));
    }
    return combineContext.forInput(windows);
  }
}
