/*
 * 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.nutch.scoring.webgraph;

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.concurrent.TimeUnit;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.GnuParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionBuilder;
import org.apache.commons.cli.Options;
import org.apache.commons.lang3.time.StopWatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.conf.Configured;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.FloatWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableUtils;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.SequenceFileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.SequenceFileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.util.Tool;
import org.apache.hadoop.util.ToolRunner;
import org.apache.nutch.util.NutchConfiguration;
import org.apache.nutch.util.NutchJob;
import org.apache.nutch.util.URLUtil;

/**
 * A tools that dumps out the top urls by number of inlinks, number of outlinks,
 * or by score, to a text file. One of the major uses of this tool is to check
 * the top scoring urls of a link analysis program such as LinkRank.
 * 
 * For number of inlinks or number of outlinks the WebGraph program will need to
 * have been run. For link analysis score a program such as LinkRank will need
 * to have been run which updates the NodeDb of the WebGraph.
 */
public class NodeDumper extends Configured implements Tool {

  private static final Logger LOG = LoggerFactory
      .getLogger(MethodHandles.lookup().lookupClass());

  private static enum DumpType {
    INLINKS, OUTLINKS, SCORES
  }

  private static enum AggrType {
    SUM, MAX
  }

  private static enum NameType {
    HOST, DOMAIN
  }

  /**
   * Outputs the top urls sorted in descending order. Depending on the flag set
   * on the command line, the top urls could be for number of inlinks, for
   * number of outlinks, or for link analysis score.
   */
  public static class Sorter extends Configured {

    /**
     * Outputs the url with the appropriate number of inlinks, outlinks, or for
     * score.
     */
    public static class SorterMapper extends 
        Mapper<Text, Node, FloatWritable, Text> {

      private Configuration conf;
      private boolean inlinks = false;
      private boolean outlinks = false;

      /**
       * Configures the mapper, sets the flag for type of content and the topN number
       * if any.
       */
      @Override
      public void setup(Mapper<Text, Node, FloatWritable, Text>.Context context) {
        conf = context.getConfiguration();
	inlinks = conf.getBoolean("inlinks", false);
	outlinks = conf.getBoolean("outlinks", false);
      }

      @Override
      public void map(Text key, Node node,
          Context context) throws IOException, InterruptedException {

        float number = 0;
        if (inlinks) {
          number = node.getNumInlinks();
        } else if (outlinks) {
          number = node.getNumOutlinks();
        } else {
          number = node.getInlinkScore();
        }

        // number collected with negative to be descending
        context.write(new FloatWritable(-number), key);
      }
    }

    /**
     * Flips and collects the url and numeric sort value.
     */
    public static class SorterReducer extends
        Reducer<FloatWritable, Text, Text, FloatWritable> {
     
      private Configuration conf;
      private long topn = Long.MAX_VALUE;

      /**
       * Configures the reducer, sets the flag for type of content and the topN number
       * if any.
       */
      @Override
      public void setup(Reducer<FloatWritable, Text, Text, FloatWritable>.Context context) {
        conf = context.getConfiguration();
        topn = conf.getLong("topn", Long.MAX_VALUE);
      }

      @Override
      public void reduce(FloatWritable key, Iterable<Text> values,
          Context context) throws IOException, InterruptedException {

        // take the negative of the negative to get original value, sometimes 0
        // value are a little weird
        float val = key.get();
        FloatWritable number = new FloatWritable(val == 0 ? 0 : -val);
        long numCollected = 0;

        // collect all values, this time with the url as key
        for (Text value : values) {
          if (numCollected < topn) {
            Text url = WritableUtils.clone(value, conf);
            context.write(url, number);
            numCollected++;
          }
        }
      }
    }
  }

  /**
   * Outputs the hosts or domains with an associated value. This value consists
   * of either the number of inlinks, the number of outlinks or the score. The
   * computed value is then either the sum of all parts or the top value.
   */
  public static class Dumper extends Configured {

    /**
     * Outputs the host or domain as key for this record and numInlinks,
     * numOutlinks or score as the value.
     */
    public static class DumperMapper extends
        Mapper<Text, Node, Text, FloatWritable> {

      private Configuration conf;
      private boolean inlinks = false;
      private boolean outlinks = false;
      private boolean host = false;

      @Override
      public void setup(Mapper<Text, Node, Text, FloatWritable>.Context context) {
        conf = context.getConfiguration();
	inlinks = conf.getBoolean("inlinks", false);
        outlinks = conf.getBoolean("outlinks", false);
        host = conf.getBoolean("host", false);
      }

      @Override
      public void map(Text key, Node node,
          Context context) throws IOException, InterruptedException {

        float number = 0;
        if (inlinks) {
          number = node.getNumInlinks();
        } else if (outlinks) {
          number = node.getNumOutlinks();
        } else {
          number = node.getInlinkScore();
        }

        if (host) {
          key.set(URLUtil.getHost(key.toString()));
        } else {
          key.set(URLUtil.getDomainName(key.toString()));
        }

        context.write(key, new FloatWritable(number));
      }
    }

    /**
     * Outputs either the sum or the top value for this record.
     */
    public static class DumperReducer extends
        Reducer<Text, FloatWritable, Text, FloatWritable> {
   
      private Configuration conf;
      private long topn = Long.MAX_VALUE;
      private boolean sum = false;
   
      @Override 
      public void reduce(Text key, Iterable<FloatWritable> values,
          Context context) throws IOException, InterruptedException {

        long numCollected = 0;
        float sumOrMax = 0;
        float val = 0;

        // collect all values, this time with the url as key
        for (FloatWritable value : values) {
          if (numCollected < topn) {
            val = value.get();

            if (sum) {
              sumOrMax += val;
            } else {
              if (sumOrMax < val) {
                sumOrMax = val;
              }
            }

            numCollected++;
          } else {
            break;
          }
        }

        context.write(key, new FloatWritable(sumOrMax));
      }

      @Override
      public void setup(Reducer<Text, FloatWritable, Text, FloatWritable>.Context context) {
        conf = context.getConfiguration();
        topn = conf.getLong("topn", Long.MAX_VALUE);
        sum = conf.getBoolean("sum", false);
      }

    }
  }

  /**
   * Runs the process to dump the top urls out to a text file.
   * 
   * @param webGraphDb
   *          The {@link org.apache.nutch.scoring.webgraph.WebGraph} 
   *          from which to pull values.
   * @param type the node property type to dump, one of 
   * {@link NodeDumper.DumpType#INLINKS}, {@link NodeDumper.DumpType#OUTLINKS} 
   * or {@link NodeDumper.DumpType#SCORES}
   * @param topN maximum value of top links to dump
   * @param output a {@link org.apache.hadoop.fs.Path} to write output to
   * @param asEff if true set equals-sign as separator for 
   * <a href="https://lucene.apache.org/solr/api/org/apache/solr/schema/ExternalFileField.html">
   * Solr's ExternalFileField</a>, false otherwise
   * @param nameType either {@link NodeDumper.NameType#HOST} or 
   * {@link NodeDumper.NameType#DOMAIN}
   * @param aggrType the aggregation type, either 
   * {@link NodeDumper.AggrType#MAX} or {@link NodeDumper.AggrType#SUM}
   * @param asSequenceFile true output will be written as 
   * {@link SequenceFileOutputFormat}, otherwise default 
   * {@link TextOutputFormat}
   * @throws Exception
   *           If an error occurs while dumping the top values.
   */
  public void dumpNodes(Path webGraphDb, DumpType type, long topN, Path output,
      boolean asEff, NameType nameType, AggrType aggrType,
      boolean asSequenceFile) throws Exception {

    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    LOG.info("NodeDumper: starting");
    Path nodeDb = new Path(webGraphDb, WebGraph.NODE_DIR);

    Job dumper = Job.getInstance(getConf(), "Nutch NodeDumper: " + webGraphDb);
    Configuration conf = dumper.getConfiguration();
    FileInputFormat.addInputPath(dumper, nodeDb);
    dumper.setInputFormatClass(SequenceFileInputFormat.class);

    if (nameType == null) {
      dumper.setJarByClass(Sorter.class);
      dumper.setMapperClass(Sorter.SorterMapper.class);
      dumper.setReducerClass(Sorter.SorterReducer.class);
      dumper.setMapOutputKeyClass(FloatWritable.class);
      dumper.setMapOutputValueClass(Text.class);
    } else {
      dumper.setJarByClass(Dumper.class);
      dumper.setMapperClass(Dumper.DumperMapper.class);
      dumper.setReducerClass(Dumper.DumperReducer.class);
      dumper.setMapOutputKeyClass(Text.class);
      dumper.setMapOutputValueClass(FloatWritable.class);
    }

    dumper.setOutputKeyClass(Text.class);
    dumper.setOutputValueClass(FloatWritable.class);
    FileOutputFormat.setOutputPath(dumper, output);

    if (asSequenceFile) {
      dumper.setOutputFormatClass(SequenceFileOutputFormat.class);
    } else {
      dumper.setOutputFormatClass(TextOutputFormat.class);
    }

    dumper.setNumReduceTasks(1);
    conf.setBoolean("inlinks", type == DumpType.INLINKS);
    conf.setBoolean("outlinks", type == DumpType.OUTLINKS);
    conf.setBoolean("scores", type == DumpType.SCORES);

    conf.setBoolean("host", nameType == NameType.HOST);
    conf.setBoolean("domain", nameType == NameType.DOMAIN);
    conf.setBoolean("sum", aggrType == AggrType.SUM);
    conf.setBoolean("max", aggrType == AggrType.MAX);

    conf.setLong("topn", topN);

    // Set equals-sign as separator for Solr's ExternalFileField
    if (asEff) {
      conf.set("mapreduce.output.textoutputformat.separator", "=");
    }

    try {
      LOG.info("NodeDumper: running");
      boolean success = dumper.waitForCompletion(true);
      if (!success) {
        String message = NutchJob.getJobFailureLogMessage("NodeDumper", dumper);
        LOG.error(message);
        throw new RuntimeException(message);
      }
    } catch (IOException e) {
      LOG.error("NodeDumper job failed:", e);
      throw e;
    }
    stopWatch.stop();
    LOG.info("NodeDumper: finished, elapsed: {} ms", stopWatch.getTime(
        TimeUnit.MILLISECONDS));
  }

  public static void main(String[] args) throws Exception {
    int res = ToolRunner.run(NutchConfiguration.create(), new NodeDumper(),
        args);
    System.exit(res);
  }

  /**
   * Runs the node dumper tool.
   */
  @Override
  public int run(String[] args) throws Exception {

    Options options = new Options();
    OptionBuilder.withArgName("help");
    OptionBuilder.withDescription("show this help message");
    Option helpOpts = OptionBuilder.create("help");
    options.addOption(helpOpts);

    OptionBuilder.withArgName("webgraphdb");
    OptionBuilder.hasArg();
    OptionBuilder.withDescription("the web graph database to use");
    Option webGraphDbOpts = OptionBuilder.create("webgraphdb");
    options.addOption(webGraphDbOpts);

    OptionBuilder.withArgName("inlinks");
    OptionBuilder.withDescription("show highest inlinks");
    Option inlinkOpts = OptionBuilder.create("inlinks");
    options.addOption(inlinkOpts);

    OptionBuilder.withArgName("outlinks");
    OptionBuilder.withDescription("show highest outlinks");
    Option outlinkOpts = OptionBuilder.create("outlinks");
    options.addOption(outlinkOpts);

    OptionBuilder.withArgName("scores");
    OptionBuilder.withDescription("show highest scores");
    Option scoreOpts = OptionBuilder.create("scores");
    options.addOption(scoreOpts);

    OptionBuilder.withArgName("topn");
    OptionBuilder.hasOptionalArg();
    OptionBuilder.withDescription("show topN scores");
    Option topNOpts = OptionBuilder.create("topn");
    options.addOption(topNOpts);

    OptionBuilder.withArgName("output");
    OptionBuilder.hasArg();
    OptionBuilder.withDescription("the output directory to use");
    Option outputOpts = OptionBuilder.create("output");
    options.addOption(outputOpts);

    OptionBuilder.withArgName("asEff");
    OptionBuilder
        .withDescription("Solr ExternalFileField compatible output format");
    Option effOpts = OptionBuilder.create("asEff");
    options.addOption(effOpts);

    OptionBuilder.hasArgs(2);
    OptionBuilder.withDescription("group <host|domain> <sum|max>");
    Option groupOpts = OptionBuilder.create("group");
    options.addOption(groupOpts);

    OptionBuilder.withArgName("asSequenceFile");
    OptionBuilder.withDescription("whether to output as a sequencefile");
    Option sequenceFileOpts = OptionBuilder.create("asSequenceFile");
    options.addOption(sequenceFileOpts);

    CommandLineParser parser = new GnuParser();
    try {

      CommandLine line = parser.parse(options, args);
      if (line.hasOption("help") || !line.hasOption("webgraphdb")) {
        HelpFormatter formatter = new HelpFormatter();
        formatter.printHelp("NodeDumper", options);
        return -1;
      }

      String webGraphDb = line.getOptionValue("webgraphdb");
      boolean inlinks = line.hasOption("inlinks");
      boolean outlinks = line.hasOption("outlinks");

      long topN = (line.hasOption("topn") ? Long.parseLong(line
          .getOptionValue("topn")) : Long.MAX_VALUE);

      // get the correct dump type
      String output = line.getOptionValue("output");
      DumpType type = (inlinks ? DumpType.INLINKS
          : outlinks ? DumpType.OUTLINKS : DumpType.SCORES);

      NameType nameType = null;
      AggrType aggrType = null;
      String[] group = line.getOptionValues("group");
      if (group != null && group.length == 2) {
        nameType = (group[0].equals("host") ? NameType.HOST : group[0]
            .equals("domain") ? NameType.DOMAIN : null);
        aggrType = (group[1].equals("sum") ? AggrType.SUM : group[1]
            .equals("sum") ? AggrType.MAX : null);
      }

      // Use ExternalFileField?
      boolean asEff = line.hasOption("asEff");
      boolean asSequenceFile = line.hasOption("asSequenceFile");

      dumpNodes(new Path(webGraphDb), type, topN, new Path(output), asEff,
          nameType, aggrType, asSequenceFile);
      return 0;
    } catch (Exception e) {
      LOG.error("NodeDumper:", e);
      return -2;
    }
  }
}
