/*
 * 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.pig.builtin.mock;

import static org.apache.hadoop.mapreduce.lib.output.FileOutputFormat.getUniqueFile;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;

import org.apache.hadoop.fs.Path;
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.OutputCommitter;
import org.apache.hadoop.mapreduce.OutputFormat;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.log4j.Logger;
import org.apache.pig.Expression;
import org.apache.pig.LoadCaster;
import org.apache.pig.LoadFunc;
import org.apache.pig.LoadMetadata;
import org.apache.pig.PigServer;
import org.apache.pig.ResourceSchema;
import org.apache.pig.ResourceStatistics;
import org.apache.pig.StoreFuncInterface;
import org.apache.pig.StoreMetadata;
import org.apache.pig.backend.hadoop.executionengine.mapReduceLayer.PigSplit;
import org.apache.pig.data.DataBag;
import org.apache.pig.data.NonSpillableDataBag;
import org.apache.pig.data.Tuple;
import org.apache.pig.data.TupleFactory;
import org.apache.pig.impl.PigContext;
import org.apache.pig.impl.logicalLayer.schema.Schema;
import org.apache.pig.impl.util.Utils;
import org.apache.pig.parser.ParserException;

/**
 * A convenient mock Storage for unit tests
 *
 * <pre>
 *  PigServer pigServer = new PigServer(ExecType.LOCAL);
 *  Data data = resetData(pigServer);
 *      data.set("foo",
 *      tuple("a"),
 *      tuple("b"),
 *      tuple("c"),
 *      tuple(map("d","e", "f","g")),
 *      tuple(bag(tuple("h"),tuple("i")))
 *      );
 *
 *  pigServer.registerQuery("A = LOAD 'foo' USING mock.Storage();");
 *  pigServer.registerQuery("STORE A INTO 'bar' USING mock.Storage();");
 *
 *  List<Tuple> out = data.get("bar");
 *
 *  assertEquals(tuple("a"), out.get(0));
 *  assertEquals(tuple("b"), out.get(1));
 *  assertEquals(tuple("c"), out.get(2));
 *  assertEquals(tuple(map("f", "g", "d", "e" )), out.get(3));
 *  assertEquals(tuple(bag(tuple("h"),tuple("i"))), out.get(4));
 * </pre>
 * With Schema:
 *  <pre>
 *  PigServer pigServer = new PigServer(ExecType.LOCAL);
 *  Data data = resetData(pigServer);
 *
 *  data.set("foo", "blah:chararray",
 *      tuple("a"),
 *      tuple("b"),
 *      tuple("c")
 *      );
 *
 *  pigServer.registerQuery("A = LOAD 'foo' USING mock.Storage();");
 *  pigServer.registerQuery("B = FOREACH A GENERATE blah as a, blah as b;");
 *  pigServer.registerQuery("STORE B INTO 'bar' USING mock.Storage();");
 *
 *  assertEquals(schema("a:chararray,b:chararray"), data.getSchema("bar"));
 *
 *  List<Tuple> out = data.get("bar");
 *  assertEquals(tuple("a", "a"), out.get(0));
 *  assertEquals(tuple("b", "b"), out.get(1));
 *  assertEquals(tuple("c", "c"), out.get(2));
 * </pre>
 */
public class Storage extends LoadFunc implements StoreFuncInterface, LoadMetadata, StoreMetadata {
  private static final String PIG_CONTEXT_KEY = "pig.mock.storage.id";
  private static final Logger LOG = Logger.getLogger(Storage.class);
  private static Map<Integer, Data> idToData = new HashMap<Integer, Data>();
  private static TupleFactory TF = TupleFactory.getInstance();

  private static int nextId;

  /**
   * @param objects
   * @return a tuple containing the provided objects
   */
  public static Tuple tuple(Object... objects) {
    return TF.newTuple(Arrays.asList(objects));
  }

  /**
   * @param tuples
   * @return a bag containing the provided objects
   */
  public static DataBag bag(Tuple... tuples) {
    return new NonSpillableDataBag(Arrays.asList(tuples));
  }

  /**
   * @param input These params are alternating "key", "value". So the number of params MUST be even !!
   * Implementation is very similar to the TOMAP UDF.
   * So map("A", B, "C", D) generates a map "A"->B, "C"->D
   * @return a map containing the provided objects
   */
  public static Map<String, Object> map(Object... input) {
    if (input == null || input.length < 2) {
      return null;
    }

    try {
      Map<String, Object> output = new HashMap<String, Object>();

      for (int i = 0; i < input.length; i=i+2) {
        String key = (String)input[i];
        Object val = input[i+1];
        output.put(key, val);
      }

      return output;
    } catch (ClassCastException e){
      throw new IllegalArgumentException("Map key must be a String");
    } catch (ArrayIndexOutOfBoundsException e){
      throw new IllegalArgumentException("Function input must have even number of parameters");
    } catch (Exception e) {
      throw new RuntimeException("Error while creating a map", e);
    }
  }

  /**
   * @param schema
   * @return the schema represented by the string
   * @throws ParserException if the schema is invalid
   */
  public static Schema schema(String schema) throws ParserException {
	  return Utils.getSchemaFromString(schema);
  }

  /**
   * reset the store and get the Data object to access it
   * @param pigServer
   * @return Data
   */
  public static Data resetData(PigServer pigServer) {
    return resetData(pigServer.getPigContext());
  }

  /**
   * reset the store and get the Data object to access it
   * @param context
   * @return data as Data
   */
  public static Data resetData(PigContext context) {
    Properties properties = context.getProperties();

    // cleaning up previous data
    try {
      if (properties.contains(PIG_CONTEXT_KEY)) {
        Integer previousId = new Integer(properties.getProperty(PIG_CONTEXT_KEY));
        idToData.remove(previousId);
      }
    } catch (RuntimeException e) {
      LOG.warn("invalid id in context properties for "+PIG_CONTEXT_KEY, e);
    }

    // setting new Store
    int id = nextId++;
    properties.setProperty(PIG_CONTEXT_KEY, String.valueOf(id));
    Data data = new Data();
    idToData.put(id, data);
    return data;
  }

  private Data getData(Job job) throws IOException {
    String stringId = job.getConfiguration().get(PIG_CONTEXT_KEY);
    if (stringId == null) {
      throw new IOException("no Data prepared for this Script. " +
      		"You need to call Storage.resetData(pigServer.getPigContext()) first");
    }
    Data data = idToData.get(new Integer(stringId));
    if (data == null) {
      throw new IOException("no Data anymore for this Script. " +
          "Has data been reset by another Storage.resetData(pigServer.getPigContext()) ?");
    }
    return data;
  }

  private static class Parts {
    final String location;
    // TreeMap to read part files in order
    final Map<String, Collection<Tuple>> parts = new TreeMap<String, Collection<Tuple>>();

    public Parts(String location) {
      super();
      this.location = location;
    }

    public void set(String partFile, Collection<Tuple> data) {
      if (parts.put(partFile, data) != null) {
        throw new RuntimeException("the part " + partFile + " for location " + location + " already exists");
      }
    }

    public List<Tuple> getAll() {
        List<Tuple> all = new ArrayList<Tuple>();
        Set<Entry<String, Collection<Tuple>>> entrySet = parts.entrySet();
        for (Entry<String, Collection<Tuple>> entry : entrySet) {
            all.addAll(entry.getValue());
        }
        return all;
    }

  }

  /**
   * An isolated data store to avoid side effects
   *
   */
  public static class Data implements Serializable {
    private static final long serialVersionUID = 1L;
    private Map<String, Parts> locationToData = new HashMap<String, Parts>();
    private Map<String, Schema> locationToSchema = new HashMap<String, Schema>();

    /**
     * to set the data in a location with a known schema
     *
     * @param location "where" to store the tuples
     * @param schema the schema of the data
     * @param data the tuples to store
     * @throws ParserException if schema is invalid
     */
    public void set(String location, String schema, Collection<Tuple> data) throws ParserException {
      set(location, Utils.getSchemaFromString(schema), data);
    }

    /**
     * to set the data in a location with a known schema
     *
     * @param location "where" to store the tuples
     * @param schema
     * @param data the tuples to store
     * @throws ParserException if schema is invalid
     */
    public void set(String location, String schema, Tuple... data) throws ParserException {
      set(location, Utils.getSchemaFromString(schema), Arrays.asList(data));
    }

    /**
     * to set the data in a location with a known schema
     *
     * @param location "where" to store the tuples
     * @param schema
     * @param data the tuples to store
     */
    public void set(String location, Schema schema, Collection<Tuple> data) {
      set(location, data);
      if (locationToSchema.put(location, schema) != null) {
          throw new RuntimeException("schema already set for location "+location);
      }
    }

    /**
     * to set the data in a location with a known schema
     *
     * @param location "where" to store the tuples
     * @param schema
     * @param data the tuples to store
     */
    public void set(String location, Schema schema, Tuple... data) {
      set(location, schema, Arrays.asList(data));
    }

    /**
     * to set the data in a location
     *
     * @param location "where" to store the tuples
     * @param data the tuples to store
     */
    private void setInternal(String location, String partID, Collection<Tuple> data) {
        Parts parts = locationToData.get(location);
        if (partID == null) {
            if (parts == null) {
                partID = "mock";
            } else {
                throw new RuntimeException("Can not set location " + location + " twice");
            }
        }
        if (parts == null) {
            parts = new Parts(location);
            locationToData.put(location, parts);
        }
        parts.set(partID, data);
    }

    /**
     * to set the data in a location
     *
     * @param location "where" to store the tuples
     * @param data the tuples to store
     */
    public void set(String location, Collection<Tuple> data) {
      setInternal(location, null, data);
    }

    /**
     * to set the data in a location
     *
     * @param location "where" to store the tuples
     * @param data the tuples to store
     */
    public void set(String location, Tuple... data) {
        set(location, Arrays.asList(data));
    }

    /**
     *
     * @param location
     * @return the data in this location
     */
    public List<Tuple> get(String location) {
      if (!locationToData.containsKey(location)) {
        throw new RuntimeException("No data for location '" + location + "'");
      }
      return locationToData.get(location).getAll();
    }

    /**
     *
     * @param location
     * @return the schema stored in this location
     */
	public Schema getSchema(String location) {
		return locationToSchema.get(location);
	}

	/**
	 * to set the schema for a given location
	 * @param location
	 * @param schema
	 */
	public void setSchema(String location, Schema schema) {
		locationToSchema.put(location, schema);
	}

  }

  private String location;

  private Data data;

  private Schema schema;

  private Iterator<Tuple> dataBeingRead;
private MockRecordWriter mockRecordWriter;

  private void init(String location, Job job) throws IOException {
	  this.data = getData(job);
	  this.location = location;
	  this.schema = data.getSchema(location);
  }
  // LoadFunc

  @Override
  public String relativeToAbsolutePath(String location, Path curDir) throws IOException {
	this.location = location;
    return location;
  }

  @Override
  public void setLocation(String location, Job job) throws IOException {
    init(location, job);
    this.dataBeingRead = data.get(location).iterator();
  }

  @Override
  public InputFormat<?, ?> getInputFormat() throws IOException {
    return new MockInputFormat(location);
  }

  @Override
  public LoadCaster getLoadCaster() throws IOException {
    return super.getLoadCaster();
  }

  @Override
  public void prepareToRead(@SuppressWarnings("rawtypes") RecordReader reader, PigSplit split) throws IOException {
  }

  @Override
  public Tuple getNext() throws IOException {
    if (dataBeingRead == null) {
      throw new IOException("data was not correctly initialized in MockLoader");
    }
    return dataBeingRead.hasNext() ? dataBeingRead.next() : null;
  }

  @Override
  public void setUDFContextSignature(String signature) {
    super.setUDFContextSignature(signature);
  }

  // LoadMetaData

  @Override
  public ResourceSchema getSchema(String location, Job job) throws IOException {
	init(location, job);
  	return schema == null ? null : new ResourceSchema(schema);
  }

  @Override
  public ResourceStatistics getStatistics(String location, Job job)
  		throws IOException {
	init(location, job);
  	return null;
  }

  @Override
  public String[] getPartitionKeys(String location, Job job) throws IOException {
	init(location, job);
  	return null;
  }

  @Override
  public void setPartitionFilter(Expression partitionFilter) throws IOException {
  }

  // StoreFunc

  @Override
  public String relToAbsPathForStoreLocation(String location, Path curDir) throws IOException {
    this.location = location;
    return location;
  }

  @Override
  public OutputFormat<?, ?> getOutputFormat() throws IOException {
    return new MockOutputFormat();
  }

  @Override
  public void setStoreLocation(String location, Job job) throws IOException {
	init(location, job);
  }

  @Override
  public void checkSchema(ResourceSchema s) throws IOException {
  }

  @Override
  public void prepareToWrite(@SuppressWarnings("rawtypes") RecordWriter writer) throws IOException {
      mockRecordWriter = (MockRecordWriter) writer;
      this.data.setInternal(location, mockRecordWriter.partID, mockRecordWriter.dataBeingWritten);
  }

  @Override
  public void putNext(Tuple t) throws IOException {
      mockRecordWriter.dataBeingWritten.add(TF.newTuple(t.getAll()));
  }

  @Override
  public void setStoreFuncUDFContextSignature(String signature) {
  }

  @Override
  public void cleanupOnFailure(String location, Job job) throws IOException {
	init(location, job);
  }

  @Override
  public void cleanupOnSuccess(String location, Job job) throws IOException {
	init(location, job);
  }

  // StoreMetaData

  @Override
  public void storeStatistics(ResourceStatistics stats, String location, Job job)
  		throws IOException {
	init(location, job);
  }

  @Override
  public void storeSchema(ResourceSchema schema, String location, Job job)
  		throws IOException {
	init(location, job);
	data.setSchema(location, Schema.getPigSchema(schema));
  }

  // Mocks for LoadFunc

  private static class MockRecordReader extends RecordReader<Object, Object> {
    @Override
    public void close() throws IOException {
    }

    @Override
    public Object getCurrentKey() throws IOException, InterruptedException {
      return "mockKey";
    }

    @Override
    public Object getCurrentValue() throws IOException, InterruptedException {
      return "mockValue";
    }

    @Override
    public float getProgress() throws IOException, InterruptedException {
      return 0.5f;
    }

    @Override
    public void initialize(InputSplit split, TaskAttemptContext arg1) throws IOException,
        InterruptedException {
    }

    @Override
    public boolean nextKeyValue() throws IOException, InterruptedException {
      return true;
    }
  }

  private static class MockInputSplit extends InputSplit implements Writable  {
    private String location;

    // used through reflection by Hadoop
    @SuppressWarnings("unused")
    public MockInputSplit() {
    }

    public MockInputSplit(String location) {
      this.location = location;
    }

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

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

    @Override
    public boolean equals(Object arg0) {
      return arg0==this;
    }

    @Override
    public int hashCode() {
      return location.hashCode();
    }

    @Override
    public void readFields(DataInput arg0) throws IOException {
      location = arg0.readUTF();
    }

    @Override
    public void write(DataOutput arg0) throws IOException {
      arg0.writeUTF(location);
    }
  }

  private static class MockInputFormat extends InputFormat<Object, Object> {

    private final String location;

    public MockInputFormat(String location) {
      this.location = location;
    }

    @Override
    public RecordReader<Object, Object> createRecordReader(InputSplit arg0, TaskAttemptContext arg1)
        throws IOException, InterruptedException {
      return new MockRecordReader();
    }

    @Override
    public List<InputSplit> getSplits(JobContext jobContext) throws IOException, InterruptedException {
      return Arrays.<InputSplit>asList(new MockInputSplit(location));
    }
  }

  // mocks for StoreFunc
  private static final class MockRecordWriter extends RecordWriter<Object, Object> {

    private final List<Tuple> dataBeingWritten = new ArrayList<Tuple>();
    private final String partID;

    public MockRecordWriter(String partID) {
        super();
        this.partID = partID;
    }

    @Override
    public void close(TaskAttemptContext taskAttemptContext) throws IOException, InterruptedException {
    }

    @Override
    public void write(Object arg0, Object arg1) throws IOException, InterruptedException {
    }

  }

  private static class MockOutputCommitter extends OutputCommitter {

    @Override
    public void abortTask(TaskAttemptContext arg0) throws IOException {
    }

    @Override
    public void commitTask(TaskAttemptContext arg0) throws IOException {
    }

    @Override
    public boolean needsTaskCommit(TaskAttemptContext arg0) throws IOException {
      return true;
    }

    @Override
    public void setupJob(JobContext arg0) throws IOException {
    }

    @Override
    public void setupTask(TaskAttemptContext arg0) throws IOException {
    }

  }

  private static final class MockOutputFormat extends OutputFormat<Object, Object> {

    @Override
    public void checkOutputSpecs(JobContext arg0) throws IOException, InterruptedException {
    }

    @Override
    public OutputCommitter getOutputCommitter(TaskAttemptContext arg0) throws IOException,
    InterruptedException {
      return new MockOutputCommitter();
    }

    @Override
    public RecordWriter<Object, Object> getRecordWriter(TaskAttemptContext arg0) throws IOException,
    InterruptedException {
      if (arg0.getConfiguration().get("mapreduce.output.basename")!=null) {
          return new MockRecordWriter(arg0.getConfiguration().get("mapreduce.output.basename") + "-" +
                  arg0.getTaskAttemptID().getTaskID().getId());
      }
      return new MockRecordWriter(getUniqueFile(arg0, "part", ".mock"));
    }

  }

  @Override
  public Boolean supportsParallelWriteToStoreLocation() {
    return true;
  }
}
