/*
 * 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.lucene.benchmark.byTask.tasks;

import java.io.StreamTokenizer;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.lucene.analysis.AbstractAnalysisFactory;
import org.apache.lucene.analysis.CharFilterFactory;
import org.apache.lucene.analysis.TokenFilterFactory;
import org.apache.lucene.analysis.TokenizerFactory;
import org.apache.lucene.analysis.util.FilesystemResourceLoader;
import org.apache.lucene.benchmark.byTask.PerfRunData;
import org.apache.lucene.benchmark.byTask.utils.AnalyzerFactory;
import org.apache.lucene.util.ResourceLoaderAware;
import org.apache.lucene.util.Version;

/**
 * Analyzer factory construction task. The name given to the constructed factory may be given to
 * NewAnalyzerTask, which will call AnalyzerFactory.create().
 *
 * <p>Params are in the form argname:argvalue or argname:"argvalue" or argname:'argvalue'; use
 * backslashes to escape '"' or "'" inside a quoted value when it's used as the enclosing quotation
 * mark,
 *
 * <p>Specify params in a comma separated list of the following, in order:
 *
 * <ol>
 *   <li>Analyzer args:
 *       <ul>
 *         <li><b>Required</b>: <code>name:<i>analyzer-factory-name</i></code>
 *         <li>Optional: <code>positionIncrementGap:<i>int value</i></code> (default: 0)
 *         <li>Optional: <code>offsetGap:<i>int value</i></code> (default: 1)
 *       </ul>
 *   <li>zero or more CharFilterFactory's, followed by
 *   <li>exactly one TokenizerFactory, followed by
 *   <li>zero or more TokenFilterFactory's
 * </ol>
 *
 * Each component analysis factory may specify <code>luceneMatchVersion</code> (defaults to {@link
 * Version#LATEST}) and any of the args understood by the specified *Factory class, in the
 * above-describe param format.
 *
 * <p>Example:
 *
 * <pre>
 *     -AnalyzerFactory(name:'strip html, fold to ascii, whitespace tokenize, max 10k tokens',
 *                      positionIncrementGap:100,
 *                      HTMLStripCharFilter,
 *                      MappingCharFilter(mapping:'mapping-FoldToASCII.txt'),
 *                      WhitespaceTokenizer(luceneMatchVersion:LUCENE_5_0_0),
 *                      TokenLimitFilter(maxTokenCount:10000, consumeAllTokens:false))
 *     [...]
 *     -NewAnalyzer('strip html, fold to ascii, whitespace tokenize, max 10k tokens')
 * </pre>
 *
 * <p>AnalyzerFactory will direct analysis component factories to look for resources under the
 * directory specified in the "work.dir" property.
 */
public class AnalyzerFactoryTask extends PerfTask {
  private static final String LUCENE_ANALYSIS_PACKAGE_PREFIX = "org.apache.lucene.analysis.";
  private static final Pattern ANALYSIS_COMPONENT_SUFFIX_PATTERN =
      Pattern.compile("(?s:(?:(?:Token|Char)?Filter|Tokenizer)(?:Factory)?)$");
  private static final Pattern TRAILING_DOT_ZERO_PATTERN = Pattern.compile("\\.0$");

  private enum ArgType {
    ANALYZER_ARG,
    ANALYZER_ARG_OR_CHARFILTER_OR_TOKENIZER,
    TOKENFILTER
  }

  String factoryName = null;
  Integer positionIncrementGap = null;
  Integer offsetGap = null;
  private List<CharFilterFactory> charFilterFactories = new ArrayList<>();
  private TokenizerFactory tokenizerFactory = null;
  private List<TokenFilterFactory> tokenFilterFactories = new ArrayList<>();

  public AnalyzerFactoryTask(PerfRunData runData) {
    super(runData);
  }

  @Override
  public int doLogic() {
    return 1;
  }

  /**
   * Sets the params. Analysis component factory names may optionally include the "Factory" suffix.
   *
   * @param params analysis pipeline specification: name, (optional) positionIncrementGap,
   *     (optional) offsetGap, 0+ CharFilterFactory's, 1 TokenizerFactory, and 0+
   *     TokenFilterFactory's
   */
  @Override
  @SuppressWarnings("fallthrough")
  public void setParams(String params) {
    super.setParams(params);
    ArgType expectedArgType = ArgType.ANALYZER_ARG;

    final StreamTokenizer stok = new StreamTokenizer(new StringReader(params));
    stok.commentChar('#');
    stok.quoteChar('"');
    stok.quoteChar('\'');
    stok.eolIsSignificant(false);
    stok.ordinaryChar('(');
    stok.ordinaryChar(')');
    stok.ordinaryChar(':');
    stok.ordinaryChar(',');
    try {
      while (stok.nextToken() != StreamTokenizer.TT_EOF) {
        switch (stok.ttype) {
          case ',':
            {
              // Do nothing
              break;
            }
          case StreamTokenizer.TT_WORD:
            {
              if (expectedArgType.equals(ArgType.ANALYZER_ARG)) {
                final String argName = stok.sval;
                if (!argName.equalsIgnoreCase("name")
                    && !argName.equalsIgnoreCase("positionIncrementGap")
                    && !argName.equalsIgnoreCase("offsetGap")) {
                  throw new RuntimeException(
                      "Line #"
                          + lineno(stok)
                          + ": Missing 'name' param to AnalyzerFactory: '"
                          + params
                          + "'");
                }
                stok.nextToken();
                if (stok.ttype != ':') {
                  throw new RuntimeException(
                      "Line #"
                          + lineno(stok)
                          + ": Missing ':' after '"
                          + argName
                          + "' param to AnalyzerFactory");
                }

                stok.nextToken();
                String argValue = stok.sval;
                switch (stok.ttype) {
                  case StreamTokenizer.TT_NUMBER:
                    {
                      argValue = Double.toString(stok.nval);
                      // Drop the ".0" from numbers, for integer arguments
                      argValue = TRAILING_DOT_ZERO_PATTERN.matcher(argValue).replaceFirst("");
                      // Intentional fallthrough
                    }
                  case '"':
                  case '\'':
                  case StreamTokenizer.TT_WORD:
                    {
                      if (argName.equalsIgnoreCase("name")) {
                        factoryName = argValue;
                        expectedArgType = ArgType.ANALYZER_ARG_OR_CHARFILTER_OR_TOKENIZER;
                      } else {
                        int intArgValue = 0;
                        try {
                          intArgValue = Integer.parseInt(argValue);
                        } catch (NumberFormatException e) {
                          throw new RuntimeException(
                              "Line #"
                                  + lineno(stok)
                                  + ": Exception parsing "
                                  + argName
                                  + " value '"
                                  + argValue
                                  + "'",
                              e);
                        }
                        if (argName.equalsIgnoreCase("positionIncrementGap")) {
                          positionIncrementGap = intArgValue;
                        } else if (argName.equalsIgnoreCase("offsetGap")) {
                          offsetGap = intArgValue;
                        }
                      }
                      break;
                    }
                  case StreamTokenizer.TT_EOF:
                    {
                      throw new RuntimeException("Unexpected EOF: " + stok.toString());
                    }
                  default:
                    {
                      throw new RuntimeException(
                          "Line #" + lineno(stok) + ": Unexpected token: " + stok.toString());
                    }
                }
              } else if (expectedArgType.equals(ArgType.ANALYZER_ARG_OR_CHARFILTER_OR_TOKENIZER)) {
                final String argName = stok.sval;

                if (argName.equalsIgnoreCase("positionIncrementGap")
                    || argName.equalsIgnoreCase("offsetGap")) {
                  stok.nextToken();
                  if (stok.ttype != ':') {
                    throw new RuntimeException(
                        "Line #"
                            + lineno(stok)
                            + ": Missing ':' after '"
                            + argName
                            + "' param to AnalyzerFactory");
                  }
                  stok.nextToken();
                  int intArgValue = (int) stok.nval;
                  switch (stok.ttype) {
                    case '"':
                    case '\'':
                    case StreamTokenizer.TT_WORD:
                      {
                        intArgValue = 0;
                        try {
                          intArgValue = Integer.parseInt(stok.sval.trim());
                        } catch (NumberFormatException e) {
                          throw new RuntimeException(
                              "Line #"
                                  + lineno(stok)
                                  + ": Exception parsing "
                                  + argName
                                  + " value '"
                                  + stok.sval
                                  + "'",
                              e);
                        }
                        // Intentional fall-through
                      }
                    case StreamTokenizer.TT_NUMBER:
                      {
                        if (argName.equalsIgnoreCase("positionIncrementGap")) {
                          positionIncrementGap = intArgValue;
                        } else if (argName.equalsIgnoreCase("offsetGap")) {
                          offsetGap = intArgValue;
                        }
                        break;
                      }
                    case StreamTokenizer.TT_EOF:
                      {
                        throw new RuntimeException("Unexpected EOF: " + stok.toString());
                      }
                    default:
                      {
                        throw new RuntimeException(
                            "Line #" + lineno(stok) + ": Unexpected token: " + stok.toString());
                      }
                  }
                  break;
                }
                try {
                  final Class<? extends CharFilterFactory> clazz;
                  clazz = lookupAnalysisClass(argName, CharFilterFactory.class);
                  createAnalysisPipelineComponent(stok, clazz);
                } catch (IllegalArgumentException e) {
                  try {
                    final Class<? extends TokenizerFactory> clazz;
                    clazz = lookupAnalysisClass(argName, TokenizerFactory.class);
                    createAnalysisPipelineComponent(stok, clazz);
                    expectedArgType = ArgType.TOKENFILTER;
                  } catch (IllegalArgumentException e2) {
                    RuntimeException ex =
                        new RuntimeException(
                            "Line #"
                                + lineno(stok)
                                + ": Can't find class '"
                                + argName
                                + "' as CharFilterFactory or TokenizerFactory",
                            e2);
                    ex.addSuppressed(e);
                    throw ex;
                  }
                }
              } else { // expectedArgType = ArgType.TOKENFILTER
                final String className = stok.sval;
                final Class<? extends TokenFilterFactory> clazz;
                try {
                  clazz = lookupAnalysisClass(className, TokenFilterFactory.class);
                } catch (IllegalArgumentException e) {
                  throw new RuntimeException(
                      "Line #"
                          + lineno(stok)
                          + ": Can't find class '"
                          + className
                          + "' as TokenFilterFactory",
                      e);
                }
                createAnalysisPipelineComponent(stok, clazz);
              }
              break;
            }
          default:
            {
              throw new RuntimeException(
                  "Line #" + lineno(stok) + ": Unexpected token: " + stok.toString());
            }
        }
      }
    } catch (RuntimeException e) {
      if (e.getMessage().startsWith("Line #")) {
        throw e;
      } else {
        throw new RuntimeException("Line #" + lineno(stok) + ": ", e);
      }
    } catch (Throwable t) {
      throw new RuntimeException("Line #" + lineno(stok) + ": ", t);
    }

    final AnalyzerFactory analyzerFactory =
        new AnalyzerFactory(charFilterFactories, tokenizerFactory, tokenFilterFactories);
    analyzerFactory.setPositionIncrementGap(positionIncrementGap);
    analyzerFactory.setOffsetGap(offsetGap);
    getRunData().getAnalyzerFactories().put(factoryName, analyzerFactory);
  }

  /**
   * Instantiates the given analysis factory class after pulling params from the given stream
   * tokenizer, then stores the result in the appropriate pipeline component list.
   *
   * @param stok stream tokenizer from which to draw analysis factory params
   * @param clazz analysis factory class to instantiate
   */
  @SuppressWarnings("fallthrough")
  private void createAnalysisPipelineComponent(
      StreamTokenizer stok, Class<? extends AbstractAnalysisFactory> clazz) {
    Map<String, String> argMap = new HashMap<>();
    boolean parenthetical = false;
    try {
      WHILE_LOOP:
      while (stok.nextToken() != StreamTokenizer.TT_EOF) {
        switch (stok.ttype) {
          case ',':
            {
              if (parenthetical) {
                // Do nothing
                break;
              } else {
                // Finished reading this analysis factory configuration
                break WHILE_LOOP;
              }
            }
          case '(':
            {
              if (parenthetical) {
                throw new RuntimeException(
                    "Line #" + lineno(stok) + ": Unexpected opening parenthesis.");
              }
              parenthetical = true;
              break;
            }
          case ')':
            {
              if (parenthetical) {
                parenthetical = false;
              } else {
                throw new RuntimeException(
                    "Line #" + lineno(stok) + ": Unexpected closing parenthesis.");
              }
              break;
            }
          case StreamTokenizer.TT_WORD:
            {
              if (!parenthetical) {
                throw new RuntimeException(
                    "Line #" + lineno(stok) + ": Unexpected token '" + stok.sval + "'");
              }
              String argName = stok.sval;
              stok.nextToken();
              if (stok.ttype != ':') {
                throw new RuntimeException(
                    "Line #"
                        + lineno(stok)
                        + ": Missing ':' after '"
                        + argName
                        + "' param to "
                        + clazz.getSimpleName());
              }
              stok.nextToken();
              String argValue = stok.sval;
              switch (stok.ttype) {
                case StreamTokenizer.TT_NUMBER:
                  {
                    argValue = Double.toString(stok.nval);
                    // Drop the ".0" from numbers, for integer arguments
                    argValue = TRAILING_DOT_ZERO_PATTERN.matcher(argValue).replaceFirst("");
                    // Intentional fall-through
                  }
                case '"':
                case '\'':
                case StreamTokenizer.TT_WORD:
                  {
                    argMap.put(argName, argValue);
                    break;
                  }
                case StreamTokenizer.TT_EOF:
                  {
                    throw new RuntimeException("Unexpected EOF: " + stok.toString());
                  }
                default:
                  {
                    throw new RuntimeException(
                        "Line #" + lineno(stok) + ": Unexpected token: " + stok.toString());
                  }
              }
            }
        }
      }
      if (!argMap.containsKey("luceneMatchVersion")) {
        argMap.put("luceneMatchVersion", Version.LATEST.toString());
      }
      final AbstractAnalysisFactory instance;
      try {
        instance = clazz.getConstructor(Map.class).newInstance(argMap);
      } catch (Exception e) {
        throw new RuntimeException("Line #" + lineno(stok) + ": ", e);
      }
      if (instance instanceof ResourceLoaderAware) {
        Path baseDir = Paths.get(getRunData().getConfig().get("work.dir", "work"));
        if (!Files.isDirectory(baseDir)) {
          baseDir = Paths.get(".");
        }
        ((ResourceLoaderAware) instance)
            .inform(
                new FilesystemResourceLoader(baseDir, AnalyzerFactoryTask.class.getClassLoader()));
      }
      if (CharFilterFactory.class.isAssignableFrom(clazz)) {
        charFilterFactories.add((CharFilterFactory) instance);
      } else if (TokenizerFactory.class.isAssignableFrom(clazz)) {
        tokenizerFactory = (TokenizerFactory) instance;
      } else if (TokenFilterFactory.class.isAssignableFrom(clazz)) {
        tokenFilterFactories.add((TokenFilterFactory) instance);
      }
    } catch (RuntimeException e) {
      if (e.getMessage().startsWith("Line #")) {
        throw (e);
      } else {
        throw new RuntimeException("Line #" + lineno(stok) + ": ", e);
      }
    } catch (Throwable t) {
      throw new RuntimeException("Line #" + lineno(stok) + ": ", t);
    }
  }

  /**
   * This method looks up a class with its fully qualified name (FQN), or a short-name
   * class-simplename, or with a package suffix, assuming "org.apache.lucene.analysis." as the
   * package prefix (e.g. "standard.ClassicTokenizerFactory" -&gt;
   * "org.apache.lucene.analysis.standard.ClassicTokenizerFactory").
   *
   * <p>If className contains a period, the class is first looked up as-is, assuming that it is an
   * FQN. If this fails, lookup is retried after prepending the Lucene analysis package prefix to
   * the class name.
   *
   * <p>If className does not contain a period, the analysis SPI *Factory.lookupClass() methods are
   * used to find the class.
   *
   * @param className The name or the short name of the class.
   * @param expectedType The superclass className is expected to extend
   * @return the loaded class.
   * @throws ClassNotFoundException if lookup fails
   */
  public <T> Class<? extends T> lookupAnalysisClass(String className, Class<T> expectedType)
      throws ClassNotFoundException {
    if (className.contains(".")) {
      try {
        // First, try className == FQN
        return Class.forName(className).asSubclass(expectedType);
      } catch (ClassNotFoundException e) {
        try {
          // Second, retry lookup after prepending the Lucene analysis package prefix
          return Class.forName(LUCENE_ANALYSIS_PACKAGE_PREFIX + className).asSubclass(expectedType);
        } catch (ClassNotFoundException e1) {
          ClassNotFoundException ex =
              new ClassNotFoundException(
                  "Can't find class '"
                      + className
                      + "' or '"
                      + LUCENE_ANALYSIS_PACKAGE_PREFIX
                      + className
                      + "'",
                  e1);
          ex.addSuppressed(e);
          throw ex;
        }
      }
    }
    // No dot - use analysis SPI lookup
    final String analysisComponentName =
        ANALYSIS_COMPONENT_SUFFIX_PATTERN.matcher(className).replaceFirst("");
    if (CharFilterFactory.class.isAssignableFrom(expectedType)) {
      return CharFilterFactory.lookupClass(analysisComponentName).asSubclass(expectedType);
    } else if (TokenizerFactory.class.isAssignableFrom(expectedType)) {
      return TokenizerFactory.lookupClass(analysisComponentName).asSubclass(expectedType);
    } else if (TokenFilterFactory.class.isAssignableFrom(expectedType)) {
      return TokenFilterFactory.lookupClass(analysisComponentName).asSubclass(expectedType);
    }

    throw new ClassNotFoundException("Can't find class '" + className + "'");
  }

  /* (non-Javadoc)
   * @see org.apache.lucene.benchmark.byTask.tasks.PerfTask#supportsParams()
   */
  @Override
  public boolean supportsParams() {
    return true;
  }

  /** Returns the current line in the algorithm file */
  public int lineno(StreamTokenizer stok) {
    return getAlgLineNum() + stok.lineno();
  }
}
