content: /*************************************************************************
  *                                                                        *
  *  This file is part of the 20n/act project.                             *
  *  20n/act enables DNA prediction for synthetic biology/bioengineering.  *
  *  Copyright (C) 2017 20n Labs, Inc.                                     *
  *                                                                        *
  *  Please direct all queries to act@20n.com.                             *
  *                                                                        *
  *  This program is free software: you can redistribute it and/or modify  *
  *  it under the terms of the GNU General Public License as published by  *
  *  the Free Software Foundation, either version 3 of the License, or     *
  *  (at your option) any later version.                                   *
  *                                                                        *
  *  This program is distributed in the hope that it will be useful,       *
  *  but WITHOUT ANY WARRANTY; without even the implied warranty of        *
  *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
  *  GNU General Public License for more details.                          *
  *                                                                        *
  *  You should have received a copy of the GNU General Public License     *
  *  along with this program.  If not, see <http://www.gnu.org/licenses/>. *
  *                                                                        *
  *************************************************************************/
  
  package com.twentyn.search.substructure;
  
  import chemaxon.formats.MolFormatException;
  import chemaxon.license.LicenseManager;
  import chemaxon.sss.search.MolSearch;
  import com.fasterxml.jackson.annotation.JsonProperty;
  import com.fasterxml.jackson.core.type.TypeReference;
  import com.fasterxml.jackson.databind.ObjectMapper;
  import com.twentyn.TargetMolecule;
  import org.apache.commons.cli.CommandLine;
  import org.apache.commons.cli.CommandLineParser;
  import org.apache.commons.cli.DefaultParser;
  import org.apache.commons.cli.HelpFormatter;
  import org.apache.commons.cli.Option;
  import org.apache.commons.cli.Options;
  import org.apache.commons.cli.ParseException;
  import org.apache.commons.csv.CSVFormat;
  import org.apache.commons.daemon.Daemon;
  import org.apache.commons.daemon.DaemonContext;
  import org.apache.commons.daemon.DaemonInitException;
  import org.apache.commons.lang3.StringUtils;
  import org.apache.logging.log4j.LogManager;
  import org.apache.logging.log4j.Logger;
  import org.eclipse.jetty.http.HttpMethod;
  import org.eclipse.jetty.server.NCSARequestLog;
  import org.eclipse.jetty.server.Request;
  import org.eclipse.jetty.server.Server;
  import org.eclipse.jetty.server.handler.AbstractHandler;
  
  import javax.servlet.ServletException;
  import javax.servlet.http.HttpServletRequest;
  import javax.servlet.http.HttpServletResponse;
  import java.io.File;
  import java.io.IOException;
  import java.util.ArrayList;
  import java.util.Arrays;
  import java.util.Collections;
  import java.util.List;
  import java.util.Map;
  import java.util.stream.Collectors;
  
  public class Service implements Daemon {
    private static final Logger LOGGER = LogManager.getFormatterLogger(Service.class);
  
    public static final CSVFormat TSV_FORMAT = CSVFormat.newFormat('\t').
        withRecordSeparator('\n').withQuote('"').withIgnoreEmptyLines(true).withHeader();
  
    public static final String OPTION_CONFIG_FILE = "c";
  
    public static final List<Option.Builder> OPTION_BUILDERS = new ArrayList<Option.Builder>() {{
      add(Option.builder(OPTION_CONFIG_FILE)
          .argName("config file")
          .desc("Path to a file containing JSON configuration parameters, used instead of CLI args")
          .hasArg().required()
          .longOpt("config")
      );
  
      // Everybody needs a little help from their friends.
      add(Option.builder("h")
          .argName("help")
          .desc("Prints this help message")
          .longOpt("help")
      );
    }};
  
    private static final String HELP_MESSAGE = StringUtils.join(new String[] {
        "This class runs a web server that does substructure matching against a TSV file using a single SMILES query.  ",
        "All matching chemicals are outputted; non-matches are ignored."
    }, "");
    private static final HelpFormatter HELP_FORMATTER = new HelpFormatter();
    static {
      HELP_FORMATTER.setWidth(100);
    }
  
    private static final Long MAX_RESULTS = 100L;
  
    private static final List<TargetMolecule> TARGETS = new ArrayList<>();
  
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
  
    private Server jettyServer;
  
    public void init(String[] args) throws Exception {
      Options opts = new Options();
      for (Option.Builder b : OPTION_BUILDERS) {
        opts.addOption(b.build());
      }
  
      CommandLine cl = null;
      try {
        CommandLineParser parser = new DefaultParser();
        cl = parser.parse(opts, args);
      } catch (ParseException e) {
        System.err.format("Argument parsing failed: %s\n", e.getMessage());
        HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
        System.exit(1);
      }
  
      if (cl.hasOption("help")) {
        HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
        return;
      }
  
      File configFile = new File(cl.getOptionValue(OPTION_CONFIG_FILE));
      if (!configFile.exists() || !configFile.isFile()) {
        System.err.format("Config file at %s could not be read, but is required for startup",
            configFile.getAbsolutePath());
        HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
        System.exit(1);
      }
  
      ServiceConfig config = null;
      try {
        config = OBJECT_MAPPER.readValue(configFile, new TypeReference<ServiceConfig>() {});
      } catch (IOException e) {
        System.err.format("Unable to read config file at %s: %s",
            configFile.getAbsolutePath(), e.getMessage());
        HELP_FORMATTER.printHelp(Service.class.getCanonicalName(), HELP_MESSAGE, opts, null, true);
        System.exit(1);
      }
  
      LicenseManager.setLicenseFile(config.getLicenseFile());
  
      LOGGER.info("Loading targets");
      TARGETS.addAll(TargetMolecule.loadTargets(new File(config.getReachablesFile()))); // Ensure TARGETS != null.
      LOGGER.info("Read %d targets from input TSV", TARGETS.size());
  
      LOGGER.info("Constructing service");
      jettyServer = new Server(config.getPort()); // TODO: take this as a CLI arg.
      /* Note: a ContextHandler here can help organize controllers by path, so we could use one to only dispatch /search
       * requests to the controller.  Unfortunately, because context handlers assume responsibility for an entire sub-
       * path, they always require a trailing slash on the URL, which we don't want.  Instead, we'll always send requests
       * to our controller but only accept them if their target is /search.
       */
      jettyServer.setHandler(new Controller(config));
  
      // Use Apache-style access logging, because it's The Right Thing To Do.
      NCSARequestLog logger = new NCSARequestLog();
      logger.setAppend(true);
      jettyServer.setRequestLog(logger);
    }
  
    @Override
    public void init(DaemonContext context) throws DaemonInitException, Exception {
      String args[] = context.getArguments();
      LOGGER.info("Daemon initializing with arguments: %s", StringUtils.join(args, " "));
      init(args);
    }
  
    @Override
    public void start() throws Exception {
      LOGGER.info("Starting server");
      jettyServer.start();
      LOGGER.info("Server started, waiting for termination");
    }
  
    @Override
    public void stop() throws Exception {
      jettyServer.stop();
    }
  
    @Override
    public void destroy() {
      jettyServer.destroy();
    }
  
    public static void main(String[] args) throws Exception {
      Service service = new Service();
      service.init(args);
  
      service.start();
      service.jettyServer.join();
    }
  
    public static class Controller extends AbstractHandler {
      private static final String EXPECTED_TARGET = "/search";
      private static final String PARAM_QUERY = "q";
      private static final String PARAM_SEARCH_OPTIONS = "options";
  
      SubstructureSearch substructureSearch = new SubstructureSearch();
  
      String wikiUrlBase;
      String imagesUrlBase;
  
      public Controller(ServiceConfig config) {
        this.wikiUrlBase = config.getWikiUrlPrefix();
        this.imagesUrlBase = config.getImageUrlPrefix();
  
        // Add trailing slashes so we can assume they exist later.
        if (!this.wikiUrlBase.endsWith("/")) {
          this.wikiUrlBase = this.wikiUrlBase + "/";
        }
  
        if (!this.imagesUrlBase.endsWith("/")) {
          this.imagesUrlBase = this.imagesUrlBase + "/";
        }
      }
  
      @Override
      public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
          throws IOException, ServletException {
        // Only handle /search GET queries.
        if (!EXPECTED_TARGET.equals(target)) {
          response.setStatus(HttpServletResponse.SC_NOT_FOUND);
          return;
        }
  
        if(!HttpMethod.GET.asString().equalsIgnoreCase(request.getMethod())) {
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
  
        Map<String, String[]> parameters = request.getParameterMap();
  
        // Search query is required.
        if (!parameters.containsKey(PARAM_QUERY) || parameters.get(PARAM_QUERY).length == 0) {
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
        String queryString = parameters.get(PARAM_QUERY)[0];
        if (queryString == null || queryString.isEmpty()) {
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
        // TODO: should we do additional validation of the query string here?
  
        // Search options are optional.
        List<String> searchOptions = Collections.emptyList();
        if (parameters.containsKey(PARAM_SEARCH_OPTIONS)) {
          searchOptions = Arrays.asList(parameters.get(PARAM_SEARCH_OPTIONS));
        }
  
        try {
          MolSearch search = substructureSearch.constructSearch(queryString, searchOptions);
  
          List<TargetMolecule> matches = new ArrayList<>();
          for (TargetMolecule targetMol : TARGETS) {
            if (substructureSearch.matchSubstructure(targetMol.getMolecule(), search)) {
              matches.add(targetMol);
            }
          }
  
          List<SearchResult> results = matches.stream().
              limit(MAX_RESULTS).
              map(mol -> new SearchResult(
                  // TODO: parameterize these URLs based on some CLI or configuration parameter.
                  this.imagesUrlBase + mol.getImageName(),
                  mol.getDisplayName(),
                  this.wikiUrlBase +  mol.getInchiKey())
              ).collect(Collectors.toList());
  
          // TODO: are there constants for these somewhere?
          response.addHeader("Content-type", "application/json");
  
          response.getWriter().write(OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(results));
          response.getWriter().flush();
          response.setStatus(HttpServletResponse.SC_OK);
        } catch (MolFormatException e) {
          LOGGER.warn("Caught MolFormatException: %s", e.getMessage());
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        } catch (Exception e) {
          LOGGER.error("Caught unexpected exception: %s", e.getMessage());
          response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
        baseRequest.setHandled(true);
      }
    }
  
    private static class SearchResult {
      @JsonProperty("image_name")
      String imageLink;
  
      @JsonProperty("page_name")
      String pageName;
  
      @JsonProperty("link")
      String link;
  
      private SearchResult() {
  
      }
  
      public SearchResult(String imageLink, String pageName, String link) {
        this.imageLink = imageLink;
        this.pageName = pageName;
        this.link = link;
      }
  
      public String getImageLink() {
        return imageLink;
      }
  
      public void setImageLink(String imageLink) {
        this.imageLink = imageLink;
      }
  
      public String getPageName() {
        return pageName;
      }
  
      public void setPageName(String pageName) {
        this.pageName = pageName;
      }
  
      public String getLink() {
        return link;
      }
  
      public void setLink(String link) {
        this.link = link;
      }
    }
  }
  