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

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.util.StringUtils;

import static org.apache.nutch.crawl.CrawlDatum.*;

import org.apache.nutch.scoring.ScoringFilterException;
import org.apache.nutch.scoring.ScoringFilters;

import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.Reducer.Context;
import org.apache.hadoop.io.Text;

import static org.junit.Assert.*;

import org.junit.Test;

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

/**
 * Test transitions of {@link CrawlDatum} states during an update of
 * {@link CrawlDb} (command {@literal updatedb}):
 * <ul>
 * <li>simulate updatedb with the old CrawlDatum (db status) and the new one
 * (fetch status) and test whether the resulting CrawlDatum has the appropriate
 * status.</li>
 * <li>also check for further CrawlDatum fields (signature, etc.)</li>
 * <li>and additional conditions:</li>
 * <ul>
 * <li>retry counters</li>
 * <li>signatures</li>
 * <li>configuration properties</li>
 * <li>(additional) CrawlDatums of status linked (stemming from inlinks)</li>
 * </ul>
 * </li> </ul>
 */
public class TestCrawlDbStates {

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

  protected static final byte[][] fetchDbStatusPairs = {
      { -1, STATUS_DB_UNFETCHED }, // no fetch status counter-part
      { STATUS_FETCH_SUCCESS, STATUS_DB_FETCHED },
      { STATUS_FETCH_GONE, STATUS_DB_GONE },
      { STATUS_FETCH_REDIR_TEMP, STATUS_DB_REDIR_TEMP },
      { STATUS_FETCH_REDIR_PERM, STATUS_DB_REDIR_PERM },
      { STATUS_FETCH_NOTMODIFIED, STATUS_DB_NOTMODIFIED },
      // fetch_retry does not have a CrawlDb counter-part
      { STATUS_FETCH_RETRY, -1 },
      // no fetch status counter-part for duplicates and orphans
      { -1, STATUS_DB_DUPLICATE }, { -1, STATUS_DB_ORPHAN } };

  /** tested {@link FetchSchedule} implementations */
  protected String[] schedules = { "DefaultFetchSchedule",
      "AdaptiveFetchSchedule" };

  /** CrawlDatum as result of a link */
  protected final CrawlDatum linked = new CrawlDatum(STATUS_LINKED,
      CrawlDBTestUtil.createContext().getConfiguration().getInt("db.fetch.interval.default",
          2592000), 0.1f);

  /**
   * Test the matrix of state transitions:
   * <ul>
   * <li>for all available {@link FetchSchedule} implementations</li>
   * <li>for every possible status in CrawlDb (including "not in CrawlDb")</li>
   * <li>for every possible fetch status</li>
   * <li>and zero or more (0-3) additional in-links</li>
   * </ul>
   * call {@literal updatedb} and check whether the resulting CrawlDb status is
   * the expected one.
   */
  @Test
  public void testCrawlDbStateTransitionMatrix() {
    LOG.info("Test CrawlDatum state transitions");
    Reducer<Text, CrawlDatum, Text, CrawlDatum>.Context context = CrawlDBTestUtil.createContext();
    Configuration conf = context.getConfiguration();
    CrawlDbUpdateUtil<CrawlDbReducer> updateDb = null;
    try {
      updateDb = new CrawlDbUpdateUtil<>(
          new CrawlDbReducer(), context);
    } catch (IOException e) {
      e.printStackTrace();
    }
    int retryMax = conf.getInt("db.fetch.retry.max", 3);
    for (String sched : schedules) {
      LOG.info("Testing state transitions with {}", sched);
      conf.set("db.fetch.schedule.class", "org.apache.nutch.crawl." + sched);
      FetchSchedule schedule = FetchScheduleFactory
          .getFetchSchedule(conf);
      for (int i = 0; i < fetchDbStatusPairs.length; i++) {
        byte fromDbStatus = fetchDbStatusPairs[i][1];
        for (int j = 0; j < fetchDbStatusPairs.length; j++) {
          byte fetchStatus = fetchDbStatusPairs[j][0];
          CrawlDatum fromDb = null;
          if (fromDbStatus == -1) {
            // nothing yet in CrawlDb
            // CrawlDatum added by FreeGenerator or via outlink
          } else {
            fromDb = new CrawlDatum();
            fromDb.setStatus(fromDbStatus);
            // initialize fetchInterval:
            schedule.initializeSchedule(CrawlDbUpdateUtil.dummyURL, fromDb);
          }
          // expected db status
          byte toDbStatus = fetchDbStatusPairs[j][1];
          if (fetchStatus == -1) {
            if (fromDbStatus == -1) {
              // nothing fetched yet: new document detected via outlink
              toDbStatus = STATUS_DB_UNFETCHED;
            } else {
              // nothing fetched but new inlinks detected: status is unchanged
              toDbStatus = fromDbStatus;
            }
          } else if (fetchStatus == STATUS_FETCH_RETRY) {
            // a simple test of fetch_retry (without retries)
            if (fromDb == null || fromDb.getRetriesSinceFetch() < retryMax) {
              toDbStatus = STATUS_DB_UNFETCHED;
            } else {
              toDbStatus = STATUS_DB_GONE;
            }
          }
          String fromDbStatusName = (fromDbStatus == -1 ? "<not in CrawlDb>"
              : getStatusName(fromDbStatus));
          String fetchStatusName = (fetchStatus == -1 ? "<only inlinks>"
              : CrawlDatum.getStatusName(fetchStatus));
          LOG.info("{} + {} => {}", fromDbStatusName, fetchStatusName,
              getStatusName(toDbStatus));
          List<CrawlDatum> values = new ArrayList<CrawlDatum>();
          for (int l = 0; l <= 2; l++) { // number of additional in-links
            CrawlDatum fetch = null;
            if (fetchStatus == -1) {
              // nothing fetched, need at least one in-link
              if (l == 0)
                continue;
            } else {
              fetch = new CrawlDatum();
              if (fromDb != null) {
                fetch.set(fromDb);
              } else {
                // not yet in CrawlDb: added by FreeGenerator
                schedule.initializeSchedule(CrawlDbUpdateUtil.dummyURL, fetch);
              }
              fetch.setStatus(fetchStatus);
              fetch.setFetchTime(System.currentTimeMillis());
            }
            if (fromDb != null)
              values.add(fromDb);
            if (fetch != null)
              values.add(fetch);
            for (int n = 0; n < l; n++) {
              values.add(linked);
            }
            List<CrawlDatum> res = updateDb.update(values);
            if (res.size() != 1) {
              fail("CrawlDb update didn't result in one single CrawlDatum per URL");
              continue;
            }
            byte status = res.get(0).getStatus();
            if (status != toDbStatus) {
              fail("CrawlDb update for " + fromDbStatusName + " and "
                  + fetchStatusName + " and " + l + " inlinks results in "
                  + getStatusName(status) + " (expected: "
                  + getStatusName(toDbStatus) + ")");
            }
            values.clear();
          }
        }
      }
    }
  }

  /**
   * Test states after inject: inject must not modify the status of CrawlDatums
   * already in CrawlDb. Newly injected elements have status "db_unfetched".
   * Inject is simulated by calling {@link Injector.InjectReducer#reduce()}.
   */
  @Test
  public void testCrawlDbStatTransitionInject() {
    LOG.info("Test CrawlDatum states in Injector after inject");
    Configuration conf = CrawlDBTestUtil.createContext().getConfiguration();
    Injector.InjectReducer injector = new Injector.InjectReducer();
    CrawlDbUpdateTestDriver<Injector.InjectReducer> injectDriver =
        new CrawlDbUpdateTestDriver<Injector.InjectReducer>(injector, conf);
    ScoringFilters scfilters = new ScoringFilters(conf);
    for (String sched : schedules) {
      LOG.info("Testing inject with {}", sched);
      conf.set("db.fetch.schedule.class", "org.apache.nutch.crawl." + sched);
      FetchSchedule schedule = FetchScheduleFactory
          .getFetchSchedule(conf);
      List<CrawlDatum> values = new ArrayList<CrawlDatum>();
      for (int i = 0; i < fetchDbStatusPairs.length; i++) {
        byte fromDbStatus = fetchDbStatusPairs[i][1];
        byte toDbStatus = fromDbStatus;
        if (fromDbStatus == -1) {
          toDbStatus = STATUS_DB_UNFETCHED;
        } else {
          CrawlDatum fromDb = new CrawlDatum();
          fromDb.setStatus(fromDbStatus);
          schedule.initializeSchedule(CrawlDbUpdateUtil.dummyURL, fromDb);
          values.add(fromDb);
        }
        LOG.info("inject {} + {} => {}",
            (fromDbStatus == -1 ? "<not in CrawlDb>"
                : CrawlDatum.getStatusName(fromDbStatus)),
            getStatusName(STATUS_INJECTED), getStatusName(toDbStatus));
        CrawlDatum injected = new CrawlDatum(STATUS_INJECTED, conf.getInt(
            "db.fetch.interval.default", 2592000), 0.1f);
        schedule.initializeSchedule(CrawlDbUpdateUtil.dummyURL, injected);
        try {
          scfilters.injectedScore(CrawlDbUpdateUtil.dummyURL, injected);
        } catch (ScoringFilterException e) {
          LOG.error(StringUtils.stringifyException(e));
        }
        values.add(injected);
        List<CrawlDatum> res = injectDriver.update(values);
        if (res.size() != 1) {
          fail("Inject didn't result in one single CrawlDatum per URL");
          continue;
        }
        byte status = res.get(0).getStatus();
        if (status != toDbStatus) {
          fail("Inject for "
              + (fromDbStatus == -1 ? "" : getStatusName(fromDbStatus)
                  + " and ") + getStatusName(STATUS_INJECTED) + " results in "
              + getStatusName(status) + " (expected: "
              + getStatusName(toDbStatus) + ")");
        }
        values.clear();
      }
    }
  }

  /**
   * Test status db_notmodified detected by
   * <ul>
   * <li>signature comparison</li>
   * <li>or HTTP 304</li>
   * </ul>
   * In addition, test for all available {@link FetchSchedule} implementations
   * whether
   * <ul>
   * <li>modified time is set</li>
   * <li>re-fetch is triggered after a certain time to force the fetched content
   * to be in a recent segment (old segments are deleted, see comments in
   * {@link CrawlDbReducer#reduce(Text, Iterator, OutputCollector, Reporter)}</li>
   * </ul>
   */
  @Test
  public void testCrawlDbReducerNotModified() {
    LOG.info("Test state notmodified");
    Context context = CrawlDBTestUtil.createContext();
    Configuration conf = context.getConfiguration();;
    // test not modified detected by signature comparison
    for (String sched : schedules) {
      String desc = "test notmodified by signature comparison + " + sched;
      LOG.info(desc);
      conf.set("db.fetch.schedule.class", "org.apache.nutch.crawl." + sched);
      ContinuousCrawlTestUtil crawlUtil = new CrawlTestFetchNotModified(context);
      try {
        if (!crawlUtil.run(20)) {
          fail("failed: " + desc);
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
    // test not modified detected by HTTP 304
    for (String sched : schedules) {
      String desc = "test notmodified by HTTP 304 + " + sched;
      LOG.info(desc);
      conf.set("db.fetch.schedule.class", "org.apache.nutch.crawl." + sched);
      ContinuousCrawlTestUtil crawlUtil = new CrawlTestFetchNotModifiedHttp304(
          context);
      try {
        if (!crawlUtil.run(20)) {
          fail("failed: " + desc);
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

  protected class CrawlTestFetchNotModified extends ContinuousCrawlTestUtil {

    /** time of the current fetch */
    protected long currFetchTime;
    /** time the last fetch took place */
    protected long lastFetchTime;
    /**
     * time the document was fetched first (at all or after it has been changed)
     */
    protected long firstFetchTime;
    /** elapsed duration */
    protected long elapsedDuration = 0;
    /** state in CrawlDb before the last fetch */
    protected byte previousDbState;
    /** signature in CrawlDb of previous fetch */
    protected byte[] lastSignature;

    private long maxFetchInterval;
    private FetchSchedule schedule;

    CrawlTestFetchNotModified(Reducer.Context context) {
      super(context);
      Configuration conf = context.getConfiguration();
      maxFetchInterval = conf.getLong("db.fetch.interval.max", 7776000); // default
                                                                         // = 90
                                                                         // days
      maxFetchInterval += (24 * 60 * 60); // but take one day more to avoid
                                          // false alarms
      maxFetchInterval *= 1000; // in milli-seconds
      schedule = FetchScheduleFactory.getFetchSchedule(conf);
    }

    @Override
    protected boolean check(CrawlDatum result) {
      if (lastFetchTime > 0
          && (currFetchTime - lastFetchTime) > maxFetchInterval) {
        LOG.error(
            "last effective fetch (HTTP 200, not HTTP 304), at {}, took place more than db.fetch.interval.max time, "
                + "segment containing fetched content may have been deleted",
            new Date(lastFetchTime));
        return false;
      }
      switch (result.getStatus()) {
      case STATUS_DB_NOTMODIFIED:
        // db_notmodified is correct if the document has been fetched previously
        // and it has not been changed since
        if ((previousDbState == STATUS_DB_FETCHED || previousDbState == STATUS_DB_NOTMODIFIED)) {
          if (lastSignature != null
              && result.getSignature() != null
              && SignatureComparator._compare(lastSignature,
                  result.getSignature()) != 0) {
            LOG.error(
                "document has changed (signature changed) but state is still {}",
                getStatusName(STATUS_DB_NOTMODIFIED));
            return false;
          }
          LOG.info("ok: {}", result);
          return checkModifiedTime(result, firstFetchTime);
        }
        LOG.warn("notmodified without previous fetch");
        break;
      case STATUS_DB_FETCHED:
        if (previousDbState == STATUS_DB_UNFETCHED) {
          LOG.info("ok (first fetch): {}", result);
          return checkModifiedTime(result, firstFetchTime);
        } else if (lastSignature != null
            && result.getSignature() != null
            && SignatureComparator._compare(lastSignature,
                result.getSignature()) != 0) {
          LOG.info("ok (content changed): {}", result);
          // expect modified time == now
          return checkModifiedTime(result, currFetchTime);
        } else {
          LOG.warn("document has not changed, db_notmodified expected");
        }
        break;
      case STATUS_DB_UNFETCHED:
        /**
         * Status db_unfetched is possible with {@link AdaptiveFetchSchedule}
         * because {@link CrawlDbReducer#reduce} calls
         * {@link FetchSchedule#forceRefetch} to force a re-fetch if fetch
         * interval grows too large.
         */
        if (schedule.getClass() == AdaptiveFetchSchedule.class) {
          LOG.info("state set to unfetched by AdaptiveFetchSchedule");
          if (result.getSignature() != null) {
            LOG.warn("must reset signature: {}", result);
            return false;
          }
          LOG.info("ok: {}", result);
          firstFetchTime = 0;
          return true;
        }
      }
      LOG.warn("wrong result: {}", result);
      return false;
    }

    // test modified time
    private boolean checkModifiedTime(CrawlDatum result, long modifiedTime) {
      if (modifiedTime == result.getModifiedTime()) {
        return true;
      }
      LOG.error("wrong modified time: {} (expected {})",
          new Date(result.getModifiedTime()), new Date(modifiedTime));
      return false;
    }

    @Override
    protected CrawlDatum fetch(CrawlDatum datum, long currentTime) {
      lastFetchTime = currFetchTime;
      currFetchTime = currentTime;
      if (lastFetchTime > 0)
        elapsedDuration += (currFetchTime - lastFetchTime);
      previousDbState = datum.getStatus();
      lastSignature = datum.getSignature();
      datum = super.fetch(datum, currentTime);
      if (firstFetchTime == 0) {
        firstFetchTime = currFetchTime;
      } else if (elapsedDuration < (duration / 2)) {
        // simulate frequent modifications in the first "year"
        changeContent();
        firstFetchTime = currFetchTime;
      }
      return datum;
    }
  }

  protected class CrawlTestFetchNotModifiedHttp304 extends
      CrawlTestFetchNotModified {

    CrawlTestFetchNotModifiedHttp304(Context context) {
      super(context);
    }

    @Override
    protected CrawlDatum fetch(CrawlDatum datum, long currentTime) {
      lastFetchTime = currFetchTime;
      currFetchTime = currentTime;
      previousDbState = datum.getStatus();
      lastSignature = datum.getSignature();
      int httpCode;
      /*
       * document is "really" fetched (no HTTP 304) - if last-modified time or
       * signature are unset (page has not been fetched before or fetch is
       * forced) - for test purposes, we simulate a modified after "one year"
       */
      if (datum.getModifiedTime() == 0 && datum.getSignature() == null
          || (currFetchTime - firstFetchTime) > (duration / 2)) {
        firstFetchTime = currFetchTime;
        httpCode = 200;
        datum.setStatus(STATUS_FETCH_SUCCESS);
        // modify content to change signature
        changeContent();
      } else {
        httpCode = 304;
        datum.setStatus(STATUS_FETCH_NOTMODIFIED);
      }
      LOG.info("fetched with HTTP {} => {}", httpCode, getStatusName(datum.getStatus()));
      datum.setFetchTime(currentTime);
      return datum;
    }
  }

  /**
   * NUTCH-1245: a fetch_gone should always result in a db_gone.
   * <p>
   * Even in a long-running continuous crawl, when a gone page is re-fetched
   * several times over time.
   * </p>
   */
  @Test
  public void testCrawlDbReducerPageGoneSchedule1() {
    LOG.info("NUTCH-1245: test long running continuous crawl");
    ContinuousCrawlTestUtil crawlUtil = new ContinuousCrawlTestUtil(
        STATUS_FETCH_GONE, STATUS_DB_GONE);
    try {
      if (!crawlUtil.run(20)) {
        fail("fetch_gone did not result in a db_gone (NUTCH-1245)");
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  /**
   * NUTCH-1245: a fetch_gone should always result in a db_gone.
   * <p>
   * As some kind of misconfiguration set db.fetch.interval.default to a value
   * &gt; (fetchIntervalMax * 1.5).
   * </p>
   */
  @Test
  public void testCrawlDbReducerPageGoneSchedule2() {
    LOG.info("NUTCH-1245 (misconfiguration): test with db.fetch.interval.default > (1.5 * db.fetch.interval.max)");
    Context context = CrawlDBTestUtil.createContext();
    Configuration conf = context.getConfiguration();
    int fetchIntervalMax = conf.getInt("db.fetch.interval.max", 0);
    conf.setInt("db.fetch.interval.default", 3 + (int) (fetchIntervalMax * 1.5));
    ContinuousCrawlTestUtil crawlUtil = new ContinuousCrawlTestUtil(context,
        STATUS_FETCH_GONE, STATUS_DB_GONE);
    try {
      if (!crawlUtil.run(0)) {
        fail("fetch_gone did not result in a db_gone (NUTCH-1245)");
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  /**
   * Test whether signatures are reset for "content-less" states (gone,
   * redirect, etc.): otherwise, if this state is temporary and the document
   * appears again with the old content, it may get marked as not_modified in
   * CrawlDb just after the redirect state. In this case we cannot expect
   * content in segments. Cf. NUTCH-1422: reset signature for redirects.
   */
  // TODO: can only test if solution is done in CrawlDbReducer
  @Test
  public void testSignatureReset() {
    LOG.info("NUTCH-1422 must reset signature for redirects and similar states");
    Context context = CrawlDBTestUtil.createContext();
    Configuration conf = context.getConfiguration();
    for (String sched : schedules) {
      LOG.info("Testing reset signature with {}", sched);
      conf.set("db.fetch.schedule.class", "org.apache.nutch.crawl." + sched);
      ContinuousCrawlTestUtil crawlUtil = new CrawlTestSignatureReset(context);
      try {
        if (!crawlUtil.run(20)) {
          fail("failed: signature not reset");
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

  private class CrawlTestSignatureReset extends ContinuousCrawlTestUtil {

    byte[][] noContentStates = { { STATUS_FETCH_GONE, STATUS_DB_GONE },
        { STATUS_FETCH_REDIR_TEMP, STATUS_DB_REDIR_TEMP },
        { STATUS_FETCH_REDIR_PERM, STATUS_DB_REDIR_PERM } };

    int counter = 0;
    byte fetchState;

    public CrawlTestSignatureReset(Context context) {
      super(context);
    }

    @Override
    protected CrawlDatum fetch(CrawlDatum datum, long currentTime) {
      datum = super.fetch(datum, currentTime);
      counter++;
      // flip-flopping between successful fetch and one of content-less states
      if (counter % 2 == 1) {
        fetchState = STATUS_FETCH_SUCCESS;
      } else {
        fetchState = noContentStates[(counter % 6) / 2][0];
      }
      LOG.info("Step {}: fetched with {}", counter, getStatusName(fetchState));
      datum.setStatus(fetchState);
      return datum;
    }

    @Override
    protected boolean check(CrawlDatum result) {
      if (result.getStatus() == STATUS_DB_NOTMODIFIED
          && !(fetchState == STATUS_FETCH_SUCCESS || fetchState == STATUS_FETCH_NOTMODIFIED)) {
        LOG.error("Should never get into state {} from {}",
            getStatusName(STATUS_DB_NOTMODIFIED), getStatusName(fetchState));
        return false;
      }
      if (result.getSignature() != null
          && !(result.getStatus() == STATUS_DB_FETCHED || result.getStatus() == STATUS_DB_NOTMODIFIED)) {
        LOG.error("Signature not reset in state {}", getStatusName(result.getStatus()));
        // ok here: since it's not the problem itself (the db_notmodified), but
        // the reason for it
      }
      return true;
    }

  }

}
