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

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Properties;

import org.apache.gobblin.util.HadoopUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.Writable;
import org.apache.hadoop.mapreduce.InputFormat;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;

import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Singular;
import lombok.extern.slf4j.Slf4j;


/**
 * An input format for reading Gobblin inputs (work unit and multi work unit files).
 */
@Slf4j
public class GobblinWorkUnitsInputFormat extends InputFormat<LongWritable, Text> {

  private static final String MAX_MAPPERS = GobblinWorkUnitsInputFormat.class.getName() + ".maxMappers";
  private static final String MAX_INPUT_FILES_TO_LOG = GobblinWorkUnitsInputFormat.class.getName() + ".maxInputFilesToLog";
  private static final int DEFAULT_MAX_INPUT_FILES_TO_LOG = 10;

  /**
   * Set max mappers used in MR job.
   */
  public static void setMaxMappers(Job job, int maxMappers) {
    job.getConfiguration().setInt(MAX_MAPPERS, maxMappers);
  }

  public static int getMaxMapper(Configuration conf) {
    return conf.getInt(MAX_MAPPERS, Integer.MAX_VALUE);
  }

  public static int getMaxInputFilesToLog(Configuration conf) {
    return conf.getInt(MAX_INPUT_FILES_TO_LOG, DEFAULT_MAX_INPUT_FILES_TO_LOG);
  }

  @Override
  public List<InputSplit> getSplits(JobContext context)
      throws IOException, InterruptedException {
    int maxInputFilesToLog = this.getMaxInputFilesToLog(context.getConfiguration());

    Path[] inputPaths = FileInputFormat.getInputPaths(context);
    if (inputPaths == null || inputPaths.length == 0) {
      throw new IOException("No input found!");
    }

    List<String> allPaths = Lists.newArrayList();

    for (Path path : inputPaths) {
      // path is a single work unit / multi work unit
      FileSystem fs = path.getFileSystem(context.getConfiguration());
      FileStatus[] inputs = fs.listStatus(path);

      if (inputs == null) {
        throw new IOException(String.format("Path %s does not exist.", path));
      }
      int numInputsToLog = Math.min(inputs.length, maxInputFilesToLog);
      FileStatus[] firstNumInputs = Arrays.copyOf(inputs, numInputsToLog);
      log.info(String.format("Found %d input files at %s: **first %d only** %s", inputs.length, path, numInputsToLog, Arrays.toString(firstNumInputs)));
      for (FileStatus input : inputs) {
        allPaths.add(input.getPath().toString());
      }
    }

    int maxMappers = getMaxMapper(context.getConfiguration());
    int numTasksPerMapper =
        allPaths.size() % maxMappers == 0 ? allPaths.size() / maxMappers : allPaths.size() / maxMappers + 1;

    List<InputSplit> splits = Lists.newArrayList();
    Iterator<String> pathsIt = allPaths.iterator();
    while (pathsIt.hasNext()) {
      Iterator<String> limitedIterator = Iterators.limit(pathsIt, numTasksPerMapper);
      splits.add(new GobblinSplit(Lists.newArrayList(limitedIterator)));
    }

    return splits;
  }

  @Override
  public RecordReader<LongWritable, Text> createRecordReader(InputSplit split, TaskAttemptContext context)
      throws IOException, InterruptedException {
    return new GobblinRecordReader((GobblinSplit) split);
  }

  /**
   * {@link InputSplit} that just contain the work unit / multi work unit files that each mapper should process.
   */
  @AllArgsConstructor
  @NoArgsConstructor
  @Builder
  @EqualsAndHashCode
  public static class GobblinSplit extends InputSplit implements Writable {

    /**
     * A list of {@link Path}s containing work unit / multi work unit.
     */
    @Getter
    @Singular
    private List<String> paths;

    @Override
    public void write(DataOutput out)
        throws IOException {
      out.writeInt(this.paths.size());
      for (String path : this.paths) {
        out.writeUTF(path);
      }
    }

    @Override
    public void readFields(DataInput in)
        throws IOException {
      int numPaths = in.readInt();
      this.paths = Lists.newArrayList();
      for (int i = 0; i < numPaths; i++) {
        this.paths.add(in.readUTF());
      }
    }

    @Override
    public long getLength()
        throws IOException, InterruptedException {
      return 0;
    }

    @Override
    public String[] getLocations()
        throws IOException, InterruptedException {
      return new String[0];
    }
  }

  /**
   * Returns records containing the name of the work unit / multi work unit files to process.
   */
  public static class GobblinRecordReader extends RecordReader<LongWritable, Text> {

    /**
     * A factor value that would be used to multiply with "(float) this.currentIdx / (float) this.totalPaths"
     * to reflect progress of the whole job.
     * We used to use bare "(float) this.currentIdx / (float) this.totalPaths" value for progress, the problem of that is
     * whenever deserialization of Gobblin-Workunit finished, the progress is reported as 1.
     * We could customize the progress in mapper, but we still want to measure the progress of deserialization.
     * The real progress multiplied with certain factor in (0,1) range could hopefully better represent the progress.
     */
    private static final String READER_PROGRESS_FACTOR = "mapper.readerProgressFactor" ;
    private static final float DEFAULT_READER_PROGRESS_FACTOR = 0.1f;

    private int currentIdx = -1;
    private final List<String> paths;
    private final int totalPaths;
    private Properties properties;


    public GobblinRecordReader(GobblinSplit split) {
      this.paths = split.getPaths();
      this.totalPaths = this.paths.size();
    }

    @Override
    public void initialize(InputSplit split, TaskAttemptContext context)
        throws IOException, InterruptedException {
      this.properties = HadoopUtils.getStateFromConf(context.getConfiguration()).getProperties();
    }

    @Override
    public boolean nextKeyValue()
        throws IOException, InterruptedException {
      this.currentIdx++;
      return this.currentIdx < this.totalPaths;
    }

    @Override
    public LongWritable getCurrentKey()
        throws IOException, InterruptedException {
      return new LongWritable(this.currentIdx);
    }

    @Override
    public Text getCurrentValue()
        throws IOException, InterruptedException {
      return new Text(this.paths.get(this.currentIdx));
    }

    @Override
    public float getProgress()
        throws IOException, InterruptedException {
      if (MRJobLauncher.isCustomizedProgressReportEnabled(properties)) {
        return 0.0f;
      } else {
        float factor = properties.containsKey(READER_PROGRESS_FACTOR) ?
            Float.parseFloat(properties.getProperty(READER_PROGRESS_FACTOR)) : DEFAULT_READER_PROGRESS_FACTOR;
        return factor * ((float) this.currentIdx / (float) this.totalPaths);
      }
    }

    @Override
    public void close()
        throws IOException {
    }
  }
}
