package nxm.sys.lib;

import java.awt.Component;
import java.awt.HeadlessException;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.demo.HTMLDocument;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriter.MaxFieldLength;
import org.apache.lucene.queryParser.MultiFieldQueryParser;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.Version;

import nxm.sys.inc.Commandable;
import nxm.sys.inc.InternalUseOnly;
import nxm.sys.lib.FileUtil.FnFilter;
import tools.BuildProps;

/** Search Engine dependent logic for Help Panel to search help index and display results
    (hits) to user and allow user to view result contents in their browser window.
    Logic extracted from MJHelp and Help.
    @author  Neon Ngo / Jeanette McCall
 */
@SuppressWarnings({"serial"})
@InternalUseOnly
public class HelpSearchEngine extends javax.swing.JPanel {

  /** JList model for search results */
  protected DefaultListModel<Document> resultsModel;
  /** results list */
  protected javax.swing.JList<Document> results;
  /** true if want to show file path in results list */
  private boolean showPathInResults = false;
  /** The Midas Context */
  private Midas M;

  /** The debug flag */
  boolean debug = false;

  /** Help index searcher instance */
  private Searcher searcher;
  /** pre-compiled pattern for HTML <pre> tag */
  private static final Pattern PRE_OPEN_REGEX  = Pattern.compile("(?i)<pre>");
  /** pre-compiled pattern for HTML </pre> tag */
  private static final Pattern PRE_CLOSE_REGEX  = Pattern.compile("(?i)</pre>");
  /** Implementation requires lucene.jar dependency */
  public static final boolean NO_LUCENE_DEPENDENCY = false;

  /** Constructor - calls super(), create empty resultsModel, then initialize. */
  public HelpSearchEngine () throws HeadlessException {
    super();
    M = Shell.getMidasContext();
  }

  /** Results Model to be used by results JList during initComponents. */
  protected DefaultListModel<Document> getResultsModel () { return resultsModel; }

  protected String findHits (Searcher searcher, String queryString, int maxResults) throws ParseException {
    queryString = queryString.trim();
    List<Document> hits = searcher.query(queryString, maxResults+1); // execute search
    int len = hits.size();
    String statusMessage;
    if (len == 0 || len == 1)   { statusMessage = len + " result found for: "  + queryString; }
    else if (len <= maxResults || maxResults < 0) { statusMessage = len + " results found for: " + queryString; }
    else { // else number results > maxResult (limiting result list)
      len = maxResults; // show only maxResults
      statusMessage = len +"+ results found for: " + queryString;
    }

    for (int ii=0; ii<len; ii++) {
      resultsModel.addElement(hits.get(ii));
    } // end for loop
    return statusMessage;
  }

  /** show contents of selected result's hits using the registered document viewer
      (via {@link #setViewer(nxm.sys.libg.MJHelp.DocViewerIF)}. e.g. show it in
      the browser.
   */
  protected String findResultHitPath () {
    int i = results.getSelectedIndex();
    String path = "";
    try {
      Document doc = resultsModel.get(i);
      // display document in viewer if it exists
      if (doc != null) {
        path = doc.get("path");
      }
    } catch (ArrayIndexOutOfBoundsException e) {
      // ignore this as another query may have changed the index
      Shell.warnStackTrace("showResultHit Exception: "+e, e);
    }
    return path;
  }

  /** Handle user's interaction with search results from terminal, such as selecting and
      viewing the search result contents.
      @param queryStr      Search query string
      @param sysBuildProps The BuildProps for the SYS Option Tree
      @param showpath      Show path to files for query results
   */
  @SuppressWarnings("deprecation") // for M.terminal - since 3.7.1 for NXM-2813
  public void doSearchInteraction (String queryStr,BuildProps sysBuildProps, boolean showpath) {
    if (queryStr==null || queryStr.trim().length() == 0) {
      queryStr = M.terminal.getInput("Enter search string: ", "").trim();
      if (queryStr.length()==0) return; // nothing to search for
    }

    // search help index for specified query string
    List<Document> results = searchIndex(queryStr,sysBuildProps);

    int len = results.size();
    if (len > 1) listResults(results, queryStr, showpath); // output the list of matches if more than 1 result

    // loop on what to do next
    for (int ilist=0; ilist<len; ilist++) {
      String ans="N";
      if (len>1) ans = M.terminal.getInput("Enter item number or [N]ext/Quit/List ?: ","");
      if (M.terminal.getInterrupt()>0) {               break; }

      if (ans.length()==0) ans="N";
      else ans = ans.trim().toUpperCase();

      if (ans.startsWith("Q")) {
                                                       break;
      }
      else if (ans.startsWith("L")) {
        listResults(results, queryStr, showpath);
        ilist--;                                       continue;
      }
      else if (ans.startsWith("N")) {
        if (ilist==len) {                              break; }
      }
      else {
        try {
          ilist = Integer.parseInt(ans);
        } catch (NumberFormatException nfe) {
          ilist--;                                     continue;
        }
      }

      if (ilist >= len) {                              break; }
      Object obj = results.get(ilist);

      // display result content if it is a Document Hit
      if (obj instanceof Document) {
        Document doc   = (Document) obj;
        String docPath = doc.get("path");  // get its path field
        TextFile tf    = new TextFile(M, docPath);

        if (!tf.open(TextFile.NOABORT)) continue;
        M.more(Terminal.INIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
        M.more("Displaying file: " + docPath);
        String text;
        boolean insidePreTag = false;
        while (M.more() && (text=tf.read())!=null) {
          if (!insidePreTag && PRE_OPEN_REGEX.matcher(text).matches())  insidePreTag = true;
          if ( insidePreTag && PRE_CLOSE_REGEX.matcher(text).matches()) insidePreTag = false;
          text = StringUtil.html2Text(text);                      // convert HTML to text
          if (!insidePreTag && text.trim().length()==0) continue; // ignore empty lines inside <pre> tags
          M.more(text);
        }
        M.more(Terminal.EXIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
        tf.close();
        M.println("For better formatting of the contents, view the following in your browser.\n "+docPath);  // since 3.7.1 using M.println instead of M.terminal.writeln to allow logging - NXM-1813
      }
    } // end for loop
  }

  /** List results
   *  @param list     Numbered list of search entries to the screen
   *  @param query    query string to search for (this cannot be null)
   *  @param showpath Show path to files for query results
   */
  @InternalUseOnly
  @SuppressWarnings("deprecation") // for M.terminal
  public void listResults (List<Document> list, String query, boolean showpath) {
    int  len  = list.size();
    String statusMessage = len + " result";
    if (len == 0 || len == 1) statusMessage += " found for: ";
    else                      statusMessage += "s found for: ";
    M.more(Terminal.INIT);    // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
    M.more(statusMessage + query);
    for (int i=0; i<len && M.more(); i++) {
      Object obj = list.get(i);
      if (obj instanceof Document) {
        Document doc = (Document) obj;
        String doctitle = doc.get("title"); // get its title
        String path     = doc.get("path");  // get its path field
        String filename = path.substring(path.lastIndexOf('/') + 1);
        String pathFromNMROOT = filename;
        String nmroot = Shell.getNmRoot();
        int nmrootIndex = path.indexOf(nmroot);
        if (nmrootIndex != -1) pathFromNMROOT = path.substring(nmroot.length());    // move past NMROOT in path
        if ((doctitle == null) || doctitle.equals("")) { //use the path if it has no title
          doctitle = pathFromNMROOT;
        } else if (showpath) {
          doctitle += " (" + pathFromNMROOT +")";
        } else {
          doctitle += " (" + filename +")";
        }
        String index = " "+i+") "; if (i<10) index = " "+index;
        M.more(index + " " + doctitle);
      }
    } // end for loop
    M.more(Terminal.EXIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
  }

  /** Search the help index for matches for the specified query string.
      @param queryStr      query string to search for (this cannot be null)
      @param sysBuildProps The BuildProps for the SYS Option Tree
      @return Empty List if no results are found, otherwise a List of Hits.
      @since NeXtMidas 2.5.0
   */
  @InternalUseOnly
  public List<Document> searchIndex (String queryStr,BuildProps sysBuildProps) {
    List<Document> results = new ArrayList<>(0);
    if (searcher == null) {
      String indexPath = sysBuildProps.getPath("help.index.dir");
      if (indexPath == null || indexPath.length()==0) M.error("help.index.dir not set in build.props!");
      try {
         searcher = new Searcher(indexPath);
      } catch (IOException ioe) {
        M.warning("Cannot initialize help index search. " + ioe);
      }
    }

    try {
      if (searcher != null) results = searcher.query(queryStr, 100); // execute search (limit to 100 results)
    } catch (ParseException pe) {
      String message = "Invalid search string. " + pe.getMessage();
      M.warning(message);
    }
    return results;
  }

  /** Index files for help and searches
      @param filter Filename filter to use specified the documents to index
      @param additionalDirs  '|' separated list of additional directories to
                             include in the list of locations to index documents.
      @param sysBuildProps   The BuildProps for the SYS Option Tree
      @return true if indexing is successful, otherwise false
      @since NeXtMidas 2.5.0
   */
  @InternalUseOnly
  public boolean indexFiles (String filter, String additionalDirs, BuildProps sysBuildProps) {
    boolean result         = false;
    String  indexPath      = sysBuildProps.getPath("help.index.dir");
    int     maxFieldLength = Integer.parseInt(sysBuildProps.getProperty("help.maxFieldLength", "100000"));
    int     numDocs        = 0; // total number of documents indexed
    String  locations      = sysBuildProps.getProperty("help.index.locations");
    String  useParentDirs  = sysBuildProps.getProperty("help.index.useparent");
            filter         = sysBuildProps.getProperty("help.index.filter", filter);

    if (indexPath==null || indexPath.length()==0) M.error("help.index.dir not set in build.props!");
    if (additionalDirs!=null) locations += "|" + additionalDirs;

    M.info("Creating help index at " + indexPath + " filter=" + filter +
        "\nThis may take a minute or two...");
    long startTime = System.currentTimeMillis();

    Indexer indexer = null;
    try {
      indexer = new Indexer(indexPath, true, maxFieldLength, filter);
      indexer.debug   = debug; // pass on debug flag to Indexer

      if (locations!=null && locations.length()>0) {
        indexer.setFilter(filter);
        if (debug) M.info("indexing dirs = "+locations+"  -  useparentDirs = "+useParentDirs);
        String[] dirs = locations.split("[|]");
        for (String dir : dirs) {
          dir = dir.trim();
          if (dir.length()>0) {
            boolean useParentDir = (useParentDirs.indexOf(dir) != -1); // false for most cases
            indexer.indexDocs(new File(dir), true, useParentDir);
          }
        }
      }

      numDocs = indexer.getDocCount(); // get total # docs indexed
      result  = true;                  // index is a success
    }
    catch (Exception e) {
      M.warning("Problem trying to index documents for help! " + e);
      if (debug) e.printStackTrace();
    }
    finally {
      if (indexer != null) {
        indexer.close(); // optimize and close indexer
        indexer = null;  // remove reference indexer to help GC free this object
      }
    }
    long endTime = System.currentTimeMillis();
    M.info("Help index created. Indexed "+ numDocs +" files in "+ (endTime - startTime)/1000 +" seconds.");

    return result;
  }


  /** Index files for help and searches
   *
   *  @param sysBuildProps The BuildProps for the SYS Option Tree
   *  @param filter        Optional override of the filename filter
   *  @param moreDirs      '|' separated list of additional directories to include
   *  @param debug         The debug flag
   *  @return FINISH
   *  @since NeXtMidas 2.5.0
   */
  @InternalUseOnly
  public int runHelpIndex (BuildProps sysBuildProps, String filter, String moreDirs, boolean debug) {
    boolean success        = false;
    String  indexPath      = sysBuildProps.getPath("help.index.dir");
    int     maxFieldLength = Integer.parseInt(sysBuildProps.getProperty("help.maxFieldLength", "100000"));
    int     numDocs        = 0; // total number of documents indexed
    String  locations      = sysBuildProps.getProperty("help.index.locations");
    String  useParentDirs  = sysBuildProps.getProperty("help.index.useparent");
    filter = sysBuildProps.getProperty("help.index.filter", filter);

    if (indexPath==null || indexPath.length()==0) M.error("help.index.dir not set in build.props!");
    if (moreDirs!=null) locations += "|" + moreDirs;

    M.info("Creating help index at " + indexPath + " filter=" + filter +
        "\nThis may take a minute or two...");
    long startTime = System.currentTimeMillis();

    Indexer indexer = null;
    try {
      indexer = new Indexer(indexPath, true, maxFieldLength, filter);
      indexer.debug = debug; // pass on debug flag to Indexer

      if (locations!=null && locations.length()>0) {
        indexer.setFilter(filter);
        if (debug) M.info("indexing dirs = "+locations+"  -  useparentDirs = "+useParentDirs);
        String[] dirs = locations.split("[|]");
        for (String dir : dirs) {
          dir = dir.trim();
          if (dir.length()>0) {
            boolean useParentDir = (useParentDirs.indexOf(dir) != -1); // false for most cases
            indexer.indexDocs(new File(dir), true, useParentDir);
          }
        }
      }

      numDocs = indexer.getDocCount(); // get total # docs indexed
      success  = true;                  // index is a success
    }
    catch (Exception e) {
      M.warning("Problem trying to index documents for help! " + e);
      if (debug) e.printStackTrace();
    }
    finally {
      if (indexer != null) {
        indexer.close(); // optimize and close indexer
        indexer = null;  // remove reference indexer to help GC free this object
      }
    }
    long endTime = System.currentTimeMillis();
    M.info("Help index created. Indexed "+ numDocs +" files in "+ (endTime - startTime)/1000 +" seconds.");

    if (!success) M.error("Run of Help Indexer failed!");
    return Commandable.FINISH;
  }

  /** Handle user's interaction with search results from terminal, such as selecting and
   *  viewing the search result contents. (CLI - command line interface)
   *
   *  @param sysBuildProps The BuildProps for the SYS Option Tree
   *  @param queryStr      Search string
   *  @param showpath      Show path to files for query results
   *  @return FINISH
   */
  @InternalUseOnly
  @SuppressWarnings("deprecation") // for M.terminal - since 3.7.1 for NXM-2813
  public int runHelpCLI (BuildProps sysBuildProps, String queryStr, boolean showpath) {
    if (queryStr==null || queryStr.trim().length() == 0) {
      queryStr = M.terminal.getInput("Enter search string: ", "").trim();
      if (queryStr.length()==0) return Commandable.FINISH; // nothing to search for
    }

    // search help index for specified query string
    List<Document> results = searchIndex(queryStr,sysBuildProps);

    int len = results.size();
    if (len > 1) listResults(results, queryStr, showpath); // output the list of matches if more than 1 result

    // loop on what to do next
    for (int ilist=0; ilist<len; ilist++) {
      String ans="N";
      if (len>1) ans = M.terminal.getInput("Enter item number or [N]ext/Quit/List ?: ","");
      if (M.terminal.getInterrupt()>0) {               break; }

      if (ans.length()==0) ans="N";
      else ans = ans.trim().toUpperCase();

      if (ans.startsWith("Q")) {
                                                       break;
      }
      else if (ans.startsWith("L")) {
        listResults(results, queryStr, showpath);
        ilist--;                                       continue;
      }
      else if (ans.startsWith("N")) {
        if (ilist==len) {                              break; }
      }
      else {
        try {
          ilist = Integer.parseInt(ans);
        } catch (NumberFormatException nfe) {
          ilist--;                                     continue;
        }
      }

      if (ilist >= len) {                              break; }
      Object obj = results.get(ilist);

      // display result content if it is a Document Hit
      if (obj instanceof Document) {
        Document doc   = (Document) obj;
        String docPath = doc.get("path");  // get its path field
        TextFile tf    = new TextFile(M, docPath);

        if (!tf.open(TextFile.NOABORT)) continue;
        M.more(Terminal.INIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
        M.more("Displaying file: " + docPath);
        String text;
        boolean insidePreTag = false;
        while (M.more() && (text=tf.read())!=null) {
          if (!insidePreTag && PRE_OPEN_REGEX.matcher(text).matches())  insidePreTag = true;
          if ( insidePreTag && PRE_CLOSE_REGEX.matcher(text).matches()) insidePreTag = false;
          text = StringUtil.html2Text(text);                      // convert HTML to text
          if (!insidePreTag && text.trim().length()==0) continue; // ignore empty lines inside <pre> tags
          M.more(text);
        }
        M.more(Terminal.EXIT);  // since 3.7.1 using M.more instead of M.terminal.more - NXM-1813
        tf.close();
        M.println("For better formatting of the contents, view the following in your browser.\n "+docPath);  // since 3.7.1 using M.println instead of M.terminal.writeln to allow logging - NXM-1813
      }
    } // end for loop

    return Commandable.FINISH;
  }

  /** This method gets called when a new opt is built after the initial build.
   *  In this version that is dependent on lucene.jar , it does nothing but return FINISH
   *  @param opt             The Option Tree getting built after the initial build
   *  @param sysBuildProps   The BuildProps for the newly added Option Tree
   *  @param filter          Optional override of the filename filter
   *  @param moreDirs        '|' separated list of additional directories to include
   *  @return FINISH
   */
  @InternalUseOnly
  public int updateHelpIndex (String opt, BuildProps sysBuildProps, String filter, String moreDirs) {
    return Commandable.FINISH;
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Inner classes
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Help indexer to create lucene index for searching help content.
      This class is only used internally by Help.
      @author Neon Ngo
      @since NeXtMidas 2.5.0
   */
  private static class Indexer {
    private IndexWriter writer;    // Lucene index writer to create and add documents to index
    private FnFilter    fnFilter;  // filename filter for files to index
    private static Analyzer analyzer = new CustomAnalyzer(); // Lucene analyzer for creating/searching help index.
    private boolean debug = false; // enable/disable debugging outputs

    /** Construct a help indexer to create lucene index for searching help content.
       @param indexPath path to directory where index will be stored
       @param overwrite true to create the index or overwrite the existing one; false to append to
                        the existing index
       @param maxFieldLength maximum number of terms that will be indexed for a single field in a document
       @param filter Filename filter for files to index. If null the default filter
                     ("*.html|*.htm|*.txt") will be used. Note: A blank (empty) string, means NONE.
                     See {@link FileUtil#createFnFilter(String, String)} for filename filter format.
       @throws IOException
       @since NeXtMidas 2.5.0
     */
    private Indexer (String indexPath, boolean overwrite, int maxFieldLength, String filter)
      throws IOException {
      Directory directory = FSDirectory.open(new File(indexPath));
      writer = new IndexWriter(directory, analyzer, overwrite, new MaxFieldLength(maxFieldLength));
      fnFilter = FileUtil.createFnFilter(filter,  "*.html|*.htm|*.txt");
    }

    /** Optimize and close index writer (i.e. commit changes to disk) for search to use.
        If index has been closed already, this method will ignore that exception. */
    private void close () {
      if (writer!=null) {
        try {
          writer.optimize();
          writer.close();
        } catch (AlreadyClosedException ace) {
          // It is safe to ignore this exception, writer has been close already
        } catch (Exception e) {
          Shell.printStackTrace("Exception caught in Help.Indexer.close() ",e);
        }
      }
    }

    /** Set the filename filter for files to index.
        @param filter Filename filter for files to index. A blank (empty) string, means NONE.
                      See {@link FileUtil#createFnFilter(String, String)} for filename filter format.
     */
    private void setFilter (String filter) {
      fnFilter = FileUtil.createFnFilter(filter, "");
    }

    /** Return the number of documents currently in the help index writer.
        @return number of documents currently in the help index writer
        @throws AlreadyClosedException if close() has been called before this.
      */
    private int getDocCount () throws AlreadyClosedException {
      int count = 0;
      if (writer!=null) count = writer.maxDoc();
      return count;
    }

    /** Index specified file or files in the specified path
        @param file file or path to directory to index files for help and searches.
        @param recursive set to true if index should goto subdirectories recursively
        @param useParentDir set to true if the stored path in the index for this file or files
               under this directory should point to the parent directory. This is needed for the
               training and user's guides files under the rawfiles directory.
        @throws Exception
     */
    private void indexDocs (File file, boolean recursive, boolean useParentDir) throws Exception {
      if (file.isDirectory()) {                  // if a directory
        String[] files = file.list();            // list its files
        Arrays.sort(files);                      // sort the files
        for (String filestr : files) {           // add files to index
          File afile = new File(file, filestr);
          if (file.isDirectory() && recursive) indexDocs(afile, recursive, useParentDir); // recursively index subdirectory
          else if (fnFilter.accept(afile))     addDocToIndex(afile, useParentDir);
        }
      } else if (fnFilter.accept(file)) {
        addDocToIndex(file, useParentDir);
      }
    }

    /** Add the following file to the help index using the index writer.
     @param file the file to add to the help index
     @param useParentDir set to true if want to point file path to existing file in parent directory.
                         if file does not exist in parent directory, this flag has no effect.
     @throws IOException
     @throws InterruptedException
     @throws CorruptIndexException
     */
    private final void addDocToIndex (File file, boolean useParentDir) throws IOException, InterruptedException {
      if (debug) System.out.println("Help.Indexer: parsing " + file);
      Document doc = HTMLDocument.Document(file); // parse document
      String path  = file.getPath();
      String name  = file.getName();

      if (useParentDir) {
        int pos = path.lastIndexOf(File.separatorChar);
        pos = (pos<=0 ? -1 : path.lastIndexOf(File.separatorChar, pos-1));
        if (pos != -1) {
          String fileInParentDir = path.substring(0, pos+1) + name;
          File fileInParent = new File(fileInParentDir);
          if (fileInParent.exists()) {
            // point file path to file in parent directory (since it exists)
            String newValue = fileInParentDir.replace(File.separatorChar, '/');
            doc.removeField("path"); // remove previously added path
            doc.add(new Field("path", newValue, Field.Store.YES, Field.Index.NOT_ANALYZED));
          }
        }
      }

      // get base filename (which would normally be Javadoc class API or HTML generated version of explain file)
      int dotIndex = name.lastIndexOf('.');
      if (dotIndex != -1) name = name.substring(0, dotIndex); // strip off file extension

      // if we have HTML explain file, strip out option tree and area from it's filename to get command name
      int expIndex = name.indexOf("_exp_");
      if (expIndex != -1) {
        name = name.substring(expIndex + "_exp_".length());
        // add command name in field "explain" to boost the search results for Commands
        doc.add(new Field("explain", name, Field.Store.YES, Field.Index.ANALYZED));
      }

      // add class/command name in field "midas" to boost the search results for Commands & Classes
      doc.add(new Field("midas", name, Field.Store.YES, Field.Index.ANALYZED));

      if (debug) System.out.println("Help.Indexer: adding " + path);
      writer.addDocument(doc);                    // add document to index
    }
  } // end class Indexer

  /** Help index searcher for querying/finding content in the lucene help index.
      This class public because it is used by {@link nxm.sys.intr.Help}.
      @author Neon Ngo
      @since NeXtMidas 2.5.0
   */
  @InternalUseOnly("Moved from intr.Help")
  public static class Searcher {
    private IndexSearcher searcher;    // lucene index searcher
    private QueryParser   queryParser; // lucene query parser
    /** search the following fields in the index */
    private static final String[] search_fields = {"midas", "explain", "title", "contents"};

    /** Construct a help searcher for querying the content in the specified lucene help index.
        @param indexPath path to directory to lucene help index
        @throws IOException &nbsp;
        @since NeXtMidas 2.5.0
     */
    public Searcher (String indexPath) throws IOException {
      Map<String,Float> boost = new HashMap<>(); // NTN: I got key/value type from MultiFieldQueryParser source code

      boost.put("explain", Float.valueOf(6)); // boost results for the explain field by 6
      boost.put("midas",   Float.valueOf(4)); // boost results for the midas field by 4
      boost.put("title",   Float.valueOf(2)); // boost results for the title field by 2
      queryParser  = new MultiFieldQueryParser(CustomAnalyzer.LUCENE_VERSION, search_fields, Indexer.analyzer, boost);
      // set default query terms to be ANDed together
      queryParser.setDefaultOperator(QueryParser.Operator.AND);
      queryParser.setAllowLeadingWildcard(true);
      setIndexPath(indexPath);
      BooleanQuery.setMaxClauseCount(2048); // increase from default of 1024 so we can do query for "n*"; TODO: move to config file and/or add a switch to change this
    }

    /** Minimum number of character search terms to have search be startsWith without regex
     *  @param minStartsWith min number of characters to trigger startsWith
     */
    public static void setStartsWithMin (int minStartsWith) {
      // place holder for no lucene version
    }

    /** Use Shell to display info/warn messages if Shell.getNmRoot() is set,
        otherwise display to standard output.
        @param msg String to display as an info message
        @param ex  if exception exists then this becomes a warning message
     */
    void displayMessage (String msg, Exception ex) {
      if (Shell.getNmRoot()!=null) {
        if (ex!=null) Shell.printStackTrace(msg, ex);
        else          Shell.info(msg);
      } else {
        String prepend = (ex!=null) ? "WARN: " : "INFO: ";
        System.out.println(prepend+msg);
        if (ex!=null) ex.printStackTrace();
      }
    }

    /** Set the path to help index for searches.
        @param indexDir path to directory containing the help index
    */
   private void setIndexPath (String indexDir) throws IOException {
     displayMessage("HELP: setting indexPath to " + indexDir, null);
     try {
       Directory directory = FSDirectory.open(new File(indexDir));
       searcher = new IndexSearcher(directory, true); // NOTE: this operation is slow for large
                                                      // indices (much slower than the search itself)
     } catch (IOException e) {
       // any error that happens is probably due to a permission problem or
       // non-existent index path or a corrupt index
       String msg = "Could not initialize help for index path='"+ indexDir +
                    "'. Did you run 'HELP/index' or do you have the appropriate file/dir permissions? " + e;
       displayMessage(msg, e);
       throw (e); // re-throw Exception up the chain
     }
   }

   /** Same as {@link #query(String, int)} with a max search result limit of 100.
       @param queryString search string to find in the help index
       @throws ParseException query string is invalid
       @deprecated Since NeXtMidas 2.7.1 use {@link #query(String, int)} instead
       @return List containing {@link Document} of the matching results. Returns an empty list if
               there are no matching results in the help index.
       @throws ParseException query string is invalid
    */
   @Deprecated public List<Document> query (String queryString) throws ParseException {
     return query(queryString, 100);
   }

   /** Query the help index to find matches for the specified query string.
       @param queryString search string to find in the help index
       @param maxResults  max search results to return. Use 0 or -1 for unlimited.
       @return List containing {@link Document} of the matching results. Returns an empty list if
               there are no matching results in the help index.
       @throws ParseException query string is invalid
    */
   public List<Document> query (String queryString, int maxResults) throws ParseException {
     ArrayList<Document> list = new ArrayList<>(); // list of results to return
     final int AUTO_WILDCARD_LEN = 5; // TODO make this configurable
     StringBuilder querySB = new StringBuilder(64);
     if (queryString != null && queryString.length() > 0) {
       // if only one search term, add a wildcard search for this term in the explain and midas fields
       // e.g. exp => exp || explain:exp* || midas:exp*
       if (queryString.indexOf(" ") == -1 && !queryString.endsWith("~") && !queryString.endsWith("^")) {
//       queryString = queryString + " || explain:" + queryString + "* || midas:" + queryString + "*"; // ORIG
         String term1 = (queryString.length() < AUTO_WILDCARD_LEN)? queryString : queryString + "*";
         querySB.append(term1).append(" || explain:").append(queryString).append("* || midas:").append(queryString).append('*');
       }
       else {
         String terms[] = queryString.split("\\s");
         for (String term : terms) {
           if (term.length() >= AUTO_WILDCARD_LEN && !term.endsWith("~") && !term.endsWith("^")) {
             querySB.append(term).append("* "); // auto wildcard terms AUTO_WILDCARD_LEN or longer
           } else {
             querySB.append(term).append(' ');
           }
         }
       }
       try {
         Query query = queryParser.parse(querySB.toString());     // parse the query string using the queryParser
         if (maxResults <= 0) maxResults = Integer.MAX_VALUE;     // effectively unlimited results
         TopDocs topDocHits = searcher.search(query, maxResults); // run the query
         int length = topDocHits.totalHits;
         list.ensureCapacity(length);                  // ensure capacity on results list so it does not have to resize

         for (ScoreDoc hit : topDocHits.scoreDocs) {
           int docId = hit.doc;
           Document doc = searcher.doc(docId);      // get the next document
           float score  = hit.score;                // get the document's hit/search score
           doc.add(new Field("score", Float.toString(score), Field.Store.NO, Field.Index.NOT_ANALYZED));
           list.add(doc);
         } // end for loop
       } catch (Exception ioe) {
         displayMessage("Caught Exception while searching for '" + queryString + "' ", ioe);
       }
     }
     return list;
   }
  } // end class Searcher


  /** CustomerAnalyzer that is the same as the StandardAnalyzer, except it uses single English
      alphabetical letters as stop words (i.e. "a", "b", "c", ..., "z").
      This class is only used internally by {@link Indexer} and {@link Searcher}.
      @since NeXtMidas 2.5.0
    */
  private static class CustomAnalyzer extends StandardAnalyzer {
    static final Version LUCENE_VERSION = Version.LUCENE_30;

    /** A set containing single English letters that are usually not useful for searching. */
    private static final Set<?> stopWords = StopFilter.makeStopSet(new String[] {
      "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
      "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"
    });

    public CustomAnalyzer () {
      super(LUCENE_VERSION, stopWords);
    }
  } // end class CustomAnalyzer

  /** Customer Cell Renderer for List to display search results stored in {@link Document} class. */
  @InternalUseOnly
  public class ResultsRenderer extends DefaultListCellRenderer {
   /** default constructor */
   public ResultsRenderer () {}

   /** Display value as "index. title" when showPathInResult is false (default), otherwise
       use "index. title - path". Also add the following tooltip "index. path (score)".
    */
   public Component getListCellRendererComponent (JList<?> list, Object value, int index,
       boolean isSelected, boolean cellHasFocus) {
     Component comp = super.getListCellRendererComponent(list,value,index,isSelected,cellHasFocus);
     int hitNumber = index + 1; // add one to make it 1s base for display to user
     if (value instanceof Document) {
       Document doc = (Document) value;
       String score = doc.get("score");    // get its score
       String doctitle = doc.get("title"); // get its title
       String path     = doc.get("path");  // get its path field
       if ((doctitle == null) || doctitle.equals("")) { // use the filename if it has no title (BUG 1331)
         File file = new File(path);
         doctitle = file.getName();
       } else if (showPathInResults && path!=null){
         doctitle += " - " + path;
       }
       JLabel label = ((JLabel)comp);
       label.setText(hitNumber + ". " + doctitle);
       label.setToolTipText(hitNumber + ". " + path + " (" + score + ")");
     }
     return comp;
   }
 } // end class ResultsRenderer
}
