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

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.Date;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.HashMap;
import java.util.Map;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.io.FloatWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.Writable;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.util.StringUtils;

import org.apache.nutch.crawl.CrawlDatum;
import org.apache.nutch.crawl.NutchWritable;

import com.tdunning.math.stats.TDigest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 *
 *
 */
public class UpdateHostDbReducer
  extends Reducer<Text, NutchWritable, Text, HostDatum> {

  private static final Logger LOG = LoggerFactory
      .getLogger(MethodHandles.lookup().lookupClass());
  protected ResolverThread resolverThread = null;
  protected Integer numResolverThreads = 10;
  protected static Integer purgeFailedHostsThreshold = -1;
  protected static Integer recheckInterval = 86400000;
  protected static boolean checkFailed = false;
  protected static boolean checkNew = false;
  protected static boolean checkKnown = false;
  protected static boolean checkAny = false;
  protected static boolean force = false;
  protected static long urlLimit = -1l;
  protected static long now = new Date().getTime();
  protected static String[] numericFields;
  protected static String[] stringFields;
  protected static int[] percentiles;
  protected static Text[] numericFieldWritables;
  protected static Text[] stringFieldWritables;
  protected static CrawlDatumProcessor[] crawlDatumProcessors;
  
  protected BlockingQueue<Runnable> queue = new SynchronousQueue<>();
  protected ThreadPoolExecutor executor = null;

  /**
    * Configures the thread pool and prestarts all resolver threads.
    */
  @Override
  public void setup(Reducer<Text, NutchWritable, Text, HostDatum>.Context context) {
    Configuration conf = context.getConfiguration();
    purgeFailedHostsThreshold = conf.getInt(UpdateHostDb.HOSTDB_PURGE_FAILED_HOSTS_THRESHOLD, -1);
    numResolverThreads = conf.getInt(UpdateHostDb.HOSTDB_NUM_RESOLVER_THREADS, 10);
    recheckInterval = conf.getInt(UpdateHostDb.HOSTDB_RECHECK_INTERVAL, 86400) * 1000;
    checkFailed = conf.getBoolean(UpdateHostDb.HOSTDB_CHECK_FAILED, false);
    checkNew = conf.getBoolean(UpdateHostDb.HOSTDB_CHECK_NEW, false);
    checkKnown = conf.getBoolean(UpdateHostDb.HOSTDB_CHECK_KNOWN, false);
    checkAny = checkNew || checkKnown || checkFailed;
    force = conf.getBoolean(UpdateHostDb.HOSTDB_FORCE_CHECK, false);
    urlLimit = conf.getLong(UpdateHostDb.HOSTDB_URL_LIMIT,-1l);
    numericFields = conf.getStrings(UpdateHostDb.HOSTDB_NUMERIC_FIELDS);
    stringFields = conf.getStrings(UpdateHostDb.HOSTDB_STRING_FIELDS);
    percentiles = conf.getInts(UpdateHostDb.HOSTDB_PERCENTILES);

    String[] crawlDatumProcessorClassnames = conf
        .getStrings(UpdateHostDb.HOSTDB_CRAWLDATUM_PROCESSORS);

    // Initialize classes for each CrawlDatumProcessor's class name
    if (crawlDatumProcessorClassnames != null) {
      crawlDatumProcessors = new CrawlDatumProcessor[crawlDatumProcessorClassnames.length];

      for (int i = 0; i < crawlDatumProcessorClassnames.length; i++) {
        LOG.info("Instantiating custom CrawlDatumProcessor {}",
            crawlDatumProcessorClassnames[i]);

        // Get the class
        try {
          Class<? extends CrawlDatumProcessor> processorClass = Class
              .forName(crawlDatumProcessorClassnames[i])
              .asSubclass(CrawlDatumProcessor.class);

          // Create an instance
          CrawlDatumProcessor processorImpl = processorClass
              .getConstructor(Configuration.class).newInstance(conf);

          // Add to array
          crawlDatumProcessors[i] = processorImpl;
        } catch (Exception e) {
          LOG.error(
              "Unable to instantiate crawldatum processor: {} because: {}",
              crawlDatumProcessorClassnames[i], e.getMessage(), e);
        }
      }
    }
    
    // What fields do we need to collect metadata from
    if (numericFields != null) {
      numericFieldWritables = new Text[numericFields.length];
      for (int i = 0; i < numericFields.length; i++) {
        numericFieldWritables[i] = new Text(numericFields[i]);
      }
    }
    
    if (stringFields != null) {
      stringFieldWritables = new Text[stringFields.length];
      for (int i = 0; i < stringFields.length; i++) {
        stringFieldWritables[i] = new Text(stringFields[i]);
      }
    }

    if (checkAny) {
      // Initialize the thread pool with our queue
      executor = new ThreadPoolExecutor(numResolverThreads, numResolverThreads,
          5, TimeUnit.SECONDS, queue);

      // Run all threads in the pool
      executor.prestartAllCoreThreads();
    }
  }

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

    Map<String,Map<String,Long>> stringCounts = new HashMap<>();
    Map<String,Float> maximums = new HashMap<>();
    Map<String,Float> sums = new HashMap<>(); // used to calc averages
    Map<String,Long> counts = new HashMap<>(); // used to calc averages
    Map<String,Float> minimums = new HashMap<>();
    Map<String,TDigest> tdigests = new HashMap<String,TDigest>();
    
    HostDatum hostDatum = new HostDatum();
    float score = 0;
    
    if (stringFields != null) {
      for (int i = 0; i < stringFields.length; i++) {
        stringCounts.put(stringFields[i], new HashMap<>());
      }
    }
    
    // Loop through all values until we find a non-empty HostDatum or use
    // an empty if this is a new host for the host db
    for (NutchWritable val : values) {
      final Writable value = val.get(); // unwrap
      
      // Count crawl datum status's and collect metadata from fields
      if (value instanceof CrawlDatum) {
        CrawlDatum buffer = (CrawlDatum)value;
        
        // Set the correct status field
        switch (buffer.getStatus()) {
          case CrawlDatum.STATUS_DB_UNFETCHED:
            hostDatum.setUnfetched(hostDatum.getUnfetched() + 1l);
            break;

          case CrawlDatum.STATUS_DB_FETCHED:
            hostDatum.setFetched(hostDatum.getFetched() + 1l);
            break;

          case CrawlDatum.STATUS_DB_GONE:
            hostDatum.setGone(hostDatum.getGone() + 1l);
            break;

          case CrawlDatum.STATUS_DB_REDIR_TEMP:
            hostDatum.setRedirTemp(hostDatum.getRedirTemp() + 1l);
            break;

          case CrawlDatum.STATUS_DB_REDIR_PERM:
            hostDatum.setRedirPerm(hostDatum.getRedirPerm() + 1l);
            break;

          case CrawlDatum.STATUS_DB_NOTMODIFIED:
            hostDatum.setNotModified(hostDatum.getNotModified() + 1l);
            break;
        }
        
        // Record connection failures
        if (buffer.getRetriesSinceFetch() != 0) {
          hostDatum.incConnectionFailures();
        }
        
        // Only gather metadata statistics for proper fetched pages
        if (buffer.getStatus() == CrawlDatum.STATUS_DB_FETCHED || buffer.getStatus() == CrawlDatum.STATUS_DB_NOTMODIFIED) {            
          // Deal with the string fields
          if (stringFields != null) {
            for (int i = 0; i < stringFields.length; i++) {
              // Does this field exist?
              if (buffer.getMetaData().get(stringFieldWritables[i]) != null) {
                // Get it!
                String metadataValue = null;
                try {
                  metadataValue = buffer.getMetaData().get(stringFieldWritables[i]).toString();
                } catch (Exception e) {
                  LOG.error("Metadata field {} is probably not a numeric value", stringFields[i]);
                }
              
                // Does the value exist?
                if (stringCounts.get(stringFields[i]).containsKey(metadataValue)) {
                  // Yes, increment it
                  stringCounts.get(stringFields[i]).put(metadataValue, stringCounts.get(stringFields[i]).get(metadataValue) + 1l);
                } else {
                  // Create it!
                  stringCounts.get(stringFields[i]).put(metadataValue, 1l);
                }
              }
            }
          }
          
          // Deal with the numeric fields
          if (numericFields != null) {
            for (int i = 0; i < numericFields.length; i++) {
              // Does this field exist?
              if (buffer.getMetaData().get(numericFieldWritables[i]) != null) {
                try {
                  // Get it!
                  Float metadataValue = Float.parseFloat(buffer.getMetaData().get(numericFieldWritables[i]).toString());
                  
                  // Does the median value exist?
                  if (tdigests.containsKey(numericFields[i])) {
                    tdigests.get(numericFields[i]).add(metadataValue);
                  } else {
                    // Create it!
                    TDigest tdigest = TDigest.createDigest(100);
                    tdigest.add((double)metadataValue);
                    tdigests.put(numericFields[i], tdigest);
                  }
                
                  // Does the minimum value exist?
                  if (minimums.containsKey(numericFields[i])) {
                    // Write if this is lower than existing value
                    if (metadataValue < minimums.get(numericFields[i])) {
                      minimums.put(numericFields[i], metadataValue);
                    }
                  } else {
                    // Create it!
                    minimums.put(numericFields[i], metadataValue);
                  }
                  
                  // Does the maximum value exist?
                  if (maximums.containsKey(numericFields[i])) {
                    // Write if this is lower than existing value
                    if (metadataValue > maximums.get(numericFields[i])) {
                      maximums.put(numericFields[i], metadataValue);
                    }
                  } else {
                    // Create it!
                    maximums.put(numericFields[i], metadataValue);
                  }
                  
                  // Sum it up!
                  if (sums.containsKey(numericFields[i])) {
                    // Increment
                    sums.put(numericFields[i], sums.get(numericFields[i]) + metadataValue);
                    counts.put(numericFields[i], counts.get(numericFields[i]) + 1l);
                  } else {
                    // Create it!
                    sums.put(numericFields[i], metadataValue);
                    counts.put(numericFields[i], 1l);
                  }
                } catch (Exception e) {
                  LOG.error("{} when processing values for {}", e.getMessage(), key);
                }
              }
            }
          }
        }

        // Run count phase for optional custom crawldatum processors
        if (crawlDatumProcessors != null) {
          for (CrawlDatumProcessor processor : crawlDatumProcessors) {
            processor.count(buffer);
          }
        }
      }

      // 
      else if (value instanceof HostDatum) {
        HostDatum buffer = (HostDatum)value;

        // Check homepage URL
        if (buffer.hasHomepageUrl()) {
          hostDatum.setHomepageUrl(buffer.getHomepageUrl());
        }

        // Check lastCheck timestamp
        if (!buffer.isEmpty()) {
          hostDatum.setLastCheck(buffer.getLastCheck());
        }

        // Check and set DNS failures
        if (buffer.getDnsFailures() > 0) {
          hostDatum.setDnsFailures(buffer.getDnsFailures());
        }

        // Check and set connection failures
        if (buffer.getConnectionFailures() > 0) {
          hostDatum.setConnectionFailures(buffer.getConnectionFailures());
        }
        
        // Check metadata
        if (buffer.hasMetaData()) {
          hostDatum.setMetaData(buffer.getMetaData());
        }

        // Check and set score (score from Web Graph has precedence)
        if (buffer.getScore() > 0) {
          hostDatum.setScore(buffer.getScore());
        }
      }

      // Check for the score
      else if (value instanceof FloatWritable) {
        FloatWritable buffer = (FloatWritable)value;
        score = buffer.get();
      } else {
        LOG.error("Class {} not handled", value.getClass());
      }
    }

    // Check if score was set from Web Graph
    if (score > 0) {
      hostDatum.setScore(score);
    }
    
    // Set metadata
    for (Map.Entry<String, Map<String,Long>> entry : stringCounts.entrySet()) {
      for (Map.Entry<String,Long> subEntry : entry.getValue().entrySet()) {
        hostDatum.getMetaData().put(new Text(entry.getKey() + "." + subEntry.getKey()), new LongWritable(subEntry.getValue()));
      }
    }
    for (Map.Entry<String, Float> entry : maximums.entrySet()) {
      hostDatum.getMetaData().put(new Text("max." + entry.getKey()), new FloatWritable(entry.getValue()));
    }
    for (Map.Entry<String, Float> entry : sums.entrySet()) {
      hostDatum.getMetaData().put(new Text("avg." + entry.getKey()), new FloatWritable(entry.getValue() / counts.get(entry.getKey())));
    }
    for (Map.Entry<String, TDigest> entry : tdigests.entrySet()) {
      // Emit all percentiles
      for (int i = 0; i < percentiles.length; i++) {
        hostDatum.getMetaData().put(new Text("pct" + Long.toString(percentiles[i]) + "." + entry.getKey()), new FloatWritable((float)entry.getValue().quantile(0.5)));
      }
    }      
    for (Map.Entry<String, Float> entry : minimums.entrySet()) {
      hostDatum.getMetaData().put(new Text("min." + entry.getKey()), new FloatWritable(entry.getValue()));
    }
    
    // Impose limits on minimum number of URLs?
    if (urlLimit > -1l) {
      if (hostDatum.numRecords() < urlLimit) {
        context.getCounter("UpdateHostDb", "url_limit_not_reached").increment(1);
        return;
      }
    }
    
    context.getCounter("UpdateHostDb", "total_hosts").increment(1);

    // See if this record is to be checked
    if (shouldCheck(hostDatum)) {
      // Make an entry
      resolverThread = new ResolverThread(key.toString(), hostDatum, context, purgeFailedHostsThreshold);

      // Add the entry to the queue (blocking)
      try {
        queue.put(resolverThread);
      } catch (InterruptedException e) {
        LOG.error("UpdateHostDb:", e);
      }

      // Do not progress, the datum will be written in the resolver thread
      return;
    } else if (checkAny) {
      context.getCounter("UpdateHostDb", "skipped_not_eligible").increment(1);
      LOG.debug("UpdateHostDb: {}: skipped_not_eligible", key);
    }

    // Run finalize phase for optional custom crawldatum processors
    if (crawlDatumProcessors != null) {
      for (CrawlDatumProcessor processor : crawlDatumProcessors) {
        processor.finalize(hostDatum);
      }
    }

    // Write the host datum if it wasn't written by the resolver thread
    context.write(key, hostDatum);
  }

  /**
    * Determines whether a record should be checked.
    *
    * @param datum a {@link HostDatum} to check for eligibility
    * @return true if it should be checked, false otherwise
    */
  protected boolean shouldCheck(HostDatum datum) {
    // Whether a new record is to be checked
    if (checkNew && datum.isEmpty()) {
      return true;
    }

    // Whether existing known hosts should be rechecked
    if (checkKnown && !datum.isEmpty() && datum.getDnsFailures() == 0) {
      return isEligibleForCheck(datum);
    }

    // Whether failed records are forced to be rechecked
    if (checkFailed && datum.getDnsFailures() > 0) {
      return isEligibleForCheck(datum);
    }

    // It seems this record is not to be checked
    return false;
  }

  /**
    * Determines whether a record is eligible for recheck.
    *
    * @param datum a {@link HostDatum} to check for eligibility
    * @return true if eligible for recheck, false otherwise
    */
  protected boolean isEligibleForCheck(HostDatum datum) {
    // Whether an existing host, known or unknown, if forced to be rechecked
    if (force || datum.getLastCheck().getTime() +
      (recheckInterval * datum.getDnsFailures() + 1) > now) {
      return true;
    }

    return false;
  }

  /**
    * Shut down all running threads and wait for completion.
    */
  @Override
  public void cleanup(Context context) {
    if (executor == null)
      // no DNS resolver threads running
      return;

    LOG.info("UpdateHostDb: feeder finished, waiting for shutdown");

    // If we're here all keys have been fed and we can issue a shut down
    executor.shutdown();

    boolean finished = false;

    // Wait until all resolvers have finished
    while (!finished) {
      try {
        // Wait for the executor to shut down completely
        if (!executor.isTerminated()) {
          LOG.info("UpdateHostDb: resolver threads waiting: {}",
              executor.getPoolSize());
          Thread.sleep(1000);
        } else {
          // All is well, get out
          finished = true;
        }
      } catch (InterruptedException e) {
        // Huh?
        LOG.warn(StringUtils.stringifyException(e));
      }
    }
  }
}
