/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt'
 * which is part of this source code package
 */

package com.scytl.slogger.validator;

import static java.text.MessageFormat.format;

import org.apache.log4j.Category;
import org.apache.log4j.Logger;
import org.apache.log4j.spi.LocationInfo;
import org.apache.oro.text.perl.Perl5Util;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.MatchResult;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.Perl5Matcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

/**
 * LogFilePatternReceiver can parse and tail log files, converting entries into LoggingEvents. If
 * the file doesn't exist when the receiver is initialized, the receiver will look for the file once
 * every 10 seconds.
 *
 * <p>This receiver relies on ORO Perl5 features to perform the parsing of text in the log file,
 * however the only regular expression field explicitly supported is a glob-style wildcard used to
 * ignore fields in the log file if needed. All other fields are parsed by using the supplied
 * keywords.
 *
 * <p><b>Features:</b><br> - specify the URL of the log file to be processed<br> - specify the
 * timestamp format in the file (if one exists)<br> - specify the pattern (logFormat) used in the
 * log file using keywords, a wildcard character (*) and fixed text<br> - 'tail' the file (allows
 * the contents of the file to be continually read and new events processed)<br> - supports the
 * parsing of multi-line messages and exceptions - 'hostname' property set to URL host (or 'file' if
 * not available) - 'application' property set to URL path (or value of fileURL if not available)
 *
 * <p><b>Keywords:</b><br> TIMESTAMP<br> LOGGER<br> LEVEL<br> THREAD<br> CLASS<br> FILE<br> LINE<br>
 * METHOD<br> RELATIVETIME<br> MESSAGE<br> NDC<br> PROP(key)<br>
 *
 * <p>Use a * to ignore portions of the log format that should be ignored
 *
 * <p>Example:<br> If your file's patternlayout is this:<br> <b>%d %-5p [%t] %C{2} (%F:%L) -
 * %m%n</b>
 *
 * <p>Specify this as the log format:<br> <b>TIMESTAMP LEVEL [THREAD] CLASS (FILE:LINE) -
 * MESSAGE</b>
 *
 * <p>To define a PROPERTY field, use PROP(key)
 *
 * <p>Example:<br> If you used the RELATIVETIME pattern layout character in the file, you can use
 * PROP(RELATIVETIME) in the logFormat definition to assign the RELATIVETIME field as a property on
 * the event.
 *
 * <p>If your file's patternlayout is this:<br> <b>%r [%t] %-5p %c %x - %m%n</b>
 *
 * <p>specify this as the log format:<br> <b>PROP(RELATIVETIME) [THREAD] LEVEL LOGGER * -
 * MESSAGE</b>
 *
 * <p>Note the * - it can be used to ignore a single word or sequence of words in the log file (in
 * order for the wildcard to ignore a sequence of words, the text being ignored must be followed by
 * some delimiter, like '-' or '[') - ndc is being ignored in this example.
 *
 * <p>Assign a filterExpression in order to only process events which match a filter. If a
 * filterExpression is not assigned, all events are processed.
 *
 * <p><b>Limitations:</b><br> - no support for the single-line version of throwable supported by
 * patternlayout<br> (this version of throwable will be included as the last line of the
 * message)<br> - the relativetime patternLayout character must be set as a property:
 * PROP(RELATIVETIME)<br> - messages should appear as the last field of the logFormat because the
 * variability in message content<br> - exceptions are converted if the exception stack trace (other
 * than the first line of the exception)<br> is stored in the log file with a tab followed by the
 * word 'at' as the first characters in the line<br> - tailing may fail if the file rolls over.
 *
 * <p><b>Example receiver configuration settings</b> (add these as params, specifying a
 * LogFilePatternReceiver 'plugin'):<br> param: "timestampFormat" value="yyyy-MM-d HH:mm:ss,SSS"<br>
 * param: "logFormat" value="RELATIVETIME [THREAD] LEVEL LOGGER * - MESSAGE"<br> param: "fileURL"
 * value="file:///c:/events.log"<br> param: "tailing" value="true"
 *
 * <p>This configuration will be able to process these sample events:<br> 710 [ Thread-0] DEBUG
 * first.logger first - <test> <test2>something here</test2> <test3 blah=something/> <test4>
 * <test5>something else</test5> </test4></test><br> 880 [ Thread-2] DEBUG first.logger third -
 * <test> <test2>something here</test2> <test3 blah=something/> <test4> <test5>something
 * else</test5> </test4></test><br> 880 [ Thread-0] INFO first.logger first - infomsg-0<br>
 * java.lang.Exception: someexception-first<br> at Generator2.run(Generator2.java:102)<br>
 *
 * @author Scott Deboy
 */
public class LogFilePatternReceiver {

  /**
   * Default timestamp format.
   */
  public static final String DEFAULT_TIMESTAMP_FORMAT = "yyyy-MM-d HH:mm:ss,SSS";

  private static final String PROP_START = "PROP(";

  private static final String PROP_END = ")";

  private static final String LOGGER = "LOGGER";

  private static final String MESSAGE = "MESSAGE";

  private static final String TIMESTAMP = "TIMESTAMP";

  private static final String NDC = "NDC";

  private static final String LEVEL = "LEVEL";

  private static final String THREAD = "THREAD";

  private static final String CLASS = "CLASS";

  private static final String FILE = "FILE";

  private static final String LINE = "LINE";

  private static final String METHOD = "METHOD";

  private static final String DEFAULT_HOST = "file";

  private static final String REGEXP_DEFAULT_WILDCARD = ".+?";

  private static final String REGEXP_GREEDY_WILDCARD = ".+";

  private static final String PATTERN_WILDCARD = "*";

  private static final String DEFAULT_GROUP = "(" + REGEXP_DEFAULT_WILDCARD + ")";

  private static final String GREEDY_GROUP = "(" + REGEXP_GREEDY_WILDCARD + ")";

  private static final String VALID_DATEFORMAT_CHAR_PATTERN = "[GyMwWDdFEaHkKhmsSzZ]";

  private final Set<String> keywords = new HashSet<>();

  private SimpleDateFormat dateFormat;

  private String timestampFormat;

  private String logFormat;

  private String fileUrl;

  private String encoding;

  private String host;

  private String path;

  private Perl5Util util;

  private Map<String, String> currentMap;

  private List<String> matchingKeywords;

  private String regexp;

  private Reader reader;

  private String timestampPatternText;

  /**
   * Constructor.
   */
  public LogFilePatternReceiver() {
    timestampFormat = DEFAULT_TIMESTAMP_FORMAT;
    util = null;
    keywords.add(TIMESTAMP);
    keywords.add(LOGGER);
    keywords.add(LEVEL);
    keywords.add(THREAD);
    keywords.add(CLASS);
    keywords.add(FILE);
    keywords.add(LINE);
    keywords.add(METHOD);
    keywords.add(MESSAGE);
    keywords.add(NDC);
  }

  public void activateOptions() {
    new Thread(this::syncActivateOptions).start();
  }

  public String getEncoding() {
    return encoding;
  }

  public String getFileUrl() {
    return fileUrl;
  }

  public String getLogFormat() {
    return logFormat;
  }

  public String getTimestampFormat() {
    return timestampFormat;
  }

  public void setEncoding(String encoding) {
    this.encoding = encoding;
  }

  public void setFileUrl(final String fileUrl) {
    this.fileUrl = fileUrl;
  }

  public void setLogFormat(final String logFormat) {
    this.logFormat = logFormat;
  }

  public void setTimestampFormat(final String timestampFormat) {
    this.timestampFormat = timestampFormat;
  }

  /**
   * Shuts down the receiver.
   */
  public void shutdown() {
    try {
      if (reader != null) {
        reader.close();
        reader = null;
      }
    } catch (IOException e) {
      getLogger().warn("Failed to shutdown", e);
    }
  }

  protected Category getLogger() {
    return Logger.getLogger(getClass());
  }

  /**
   * Single-thread version of activateOptions() method.
   */
  public void syncActivateOptions() {
    initialize();
    getLogger().info("attempting to load file: " + getFileUrl());

    try {
      URL url = new URL(getFileUrl());
      File file = new File(url.getFile());
      while (!file.exists()) {
        getLogger().info("file not available - will try again in 10 seconds");
        try {
          Thread.sleep(10000L);
        } catch (InterruptedException e) {
          getLogger().warn("sleep interrupted", e);
          Thread.currentThread().interrupt();
          return;
        }
      }

      try {
        Charset charset = encoding != null ? Charset.forName(encoding) : Charset.defaultCharset();
        reader = new InputStreamReader(url.openStream(), charset);
      } catch (IOException e) {
        getLogger().warn("unable to load file", e);
        return;
      }

      try {
        process(reader);
      } catch (IOException e) {
        // io exception - probably shut down
        getLogger().info("stream exception", e);
      } finally {
        shutdown();
      }

    } catch (MalformedURLException e) {
      getLogger().warn("Malformed file name: " + getFileUrl(), e);
    }
  }

  protected void initialize() {
    if (host == null && path == null) {
      try {
        URL url = new URL(fileUrl);
        host = url.getHost();
        path = url.getPath();
      } catch (MalformedURLException e) {
        getLogger().warn(format("Invalid file URL ''{0}''.", fileUrl), e);
      }
    }
    if (host == null || host.trim().isEmpty()) {
      host = DEFAULT_HOST;
    }
    if (path == null || path.trim().isEmpty()) {
      path = fileUrl;
    }
    util = new Perl5Util();
    currentMap = new LinkedHashMap<>();
    matchingKeywords = new ArrayList<>();
    if (timestampFormat != null) {
      dateFormat = new SimpleDateFormat(timestampFormat);
      timestampPatternText = convertTimestamp();
    }
    Map<Integer, String> keywordMap = new TreeMap<>();
    String newPattern = logFormat;
    int index = 0;
    int currentPosition = 0;
    String current = newPattern;
    do {
      if (index <= -1) {
        break;
      }
      index = current.indexOf(PROP_START);
      currentPosition += index;
      if (index > -1) {
        String currentProp = current.substring(current.indexOf(PROP_START));
        String prop = currentProp.substring(0, currentProp.indexOf(PROP_END) + 1);
        current = current.substring(current.indexOf(currentProp) + 1);
        String shortProp = prop.substring(PROP_START.length(), prop.length() - 1);
        keywordMap.put(new Integer(currentPosition), shortProp);
        newPattern = replace(prop, shortProp, newPattern);
      }
    } while (true);
    newPattern = replaceMetaChars(newPattern);
    newPattern = replace(PATTERN_WILDCARD, REGEXP_DEFAULT_WILDCARD, newPattern);
    Iterator<String> iter = keywords.iterator();
    do {
      if (!iter.hasNext()) {
        break;
      }
      String keyword = iter.next();
      int index2 = newPattern.indexOf(keyword);
      if (index2 > -1) {
        keywordMap.put(new Integer(index2), keyword);
      }
    } while (true);
    matchingKeywords.addAll(keywordMap.values());
    String currentPattern = newPattern;
    for (int i = 0; i < matchingKeywords.size(); i++) {
      String keyword = matchingKeywords.get(i);
      if (i == matchingKeywords.size() - 1) {
        currentPattern = replace(keyword, GREEDY_GROUP, currentPattern);
        continue;
      }
      if (TIMESTAMP.equals(keyword)) {
        currentPattern = replace(keyword, "(" + timestampPatternText + ")", currentPattern);
      } else {
        currentPattern = replace(keyword, DEFAULT_GROUP, currentPattern);
      }
    }

    regexp = currentPattern;
    getLogger().debug("regexp is " + regexp);
  }

  protected void process(final Reader unbufferedReader) throws IOException {
    BufferedReader bufferedReader = new BufferedReader(unbufferedReader);
    Perl5Compiler compiler = new Perl5Compiler();
    org.apache.oro.text.regex.Pattern regexpPattern = null;
    try {
      regexpPattern = compiler.compile(regexp);
    } catch (MalformedPatternException e) {
      throw new IllegalStateException("Bad pattern: " + regexp, e);
    }
    Perl5Matcher eventMatcher = new Perl5Matcher();
    String line;
    ValidatorLoggingEvent event;
    while (reader != null && (line = bufferedReader.readLine()) != null) {
      if (eventMatcher.matches(line, regexpPattern)) {
        event = buildEvent();
        if (event != null) {
          doPost(event);
        }
        currentMap.putAll(processEvent(eventMatcher.getMatch()));
      } else {
        ValidatorLoggingEvent unknownEvent = ValidatorLoggingEvent.newMalformedInstance(line);
        doPost(unknownEvent);
      }
    }
    event = buildEvent();
    if (event != null) {
      doPost(event);
      getLogger().debug("no further lines to process in " + fileUrl);
    }
    try {
      Thread.sleep(2000L);
    } catch (InterruptedException e) {
      getLogger().warn("sleep interrupted", e);
      Thread.currentThread().interrupt();
    }
    getLogger().debug("processing " + fileUrl + " complete");
    shutdown();
  }

  protected void doPost(ValidatorLoggingEvent event) {
    // nothing to do
  }

  protected void setHost(final String host) {
    this.host = host;
  }

  protected void setPath(final String path) {
    this.path = path;
  }

  private ValidatorLoggingEvent buildEvent() {

    if (currentMap.isEmpty()) {
      return null;
    }

    ValidatorLoggingEvent event = convertToEvent(currentMap);
    currentMap.clear();
    return event;
  }

  private String convertTimestamp() {
    return util.substitute("s/(" + VALID_DATEFORMAT_CHAR_PATTERN + ")+/\\\\w+/g", timestampFormat);
  }

  private ValidatorLoggingEvent convertToEvent(final Map<String, String> fieldMap) {

    if (fieldMap == null) {
      return null;
    }
    if (!fieldMap.containsKey(LOGGER)) {
      fieldMap.put(LOGGER, "Unknown");
    }

    ValidatorLoggingEvent.WellFormedInstanceBuilder builder =
        new ValidatorLoggingEvent.WellFormedInstanceBuilder();

    builder.setLoggerName(fieldMap.remove(LOGGER));

    long timeStamp = 0L;
    if (dateFormat != null && fieldMap.containsKey(TIMESTAMP)) {
      try {
        timeStamp = dateFormat.parse(fieldMap.remove(TIMESTAMP)).getTime();
      } catch (Exception e) {
        getLogger().warn("Invalid timestamp", e);
      }
    }
    if (timeStamp == 0L) {
      timeStamp = System.currentTimeMillis();
    }
    builder.setTimeStamp(timeStamp);

    String level = fieldMap.remove(LEVEL);
    if (level != null) {
      level = level.trim();
    }
    builder.setLevel(Level.toLevel(level));

    builder.setThreadName(fieldMap.remove(THREAD));

    String message = fieldMap.remove(MESSAGE);
    if (message == null) {
      message = "";
    }
    builder.setMessage(message);

    String className = fieldMap.remove(CLASS);
    String methodName = fieldMap.remove(METHOD);
    String fileName = fieldMap.remove(FILE);
    String lineNumber = fieldMap.remove(LINE);
    LocationInfo info = new ValidatorLocationInfo(className, methodName, fileName, lineNumber);
    builder.setLocationInformation(info);

    builder.setNdc(fieldMap.remove(NDC));

    return builder.build();
  }

  private Map<String, String> processEvent(final MatchResult result) {
    Map<String, String> map = new LinkedHashMap<>();
    for (int i = 1; i < result.groups(); i++) {
      map.put(matchingKeywords.get(i - 1), result.group(i));
    }

    return map;
  }

  private String replace(final String pattern, final String replacement, final String input) {
    return util.substitute(
        "s/" + Perl5Compiler.quotemeta(pattern) + "/" + Perl5Compiler.quotemeta(replacement) + "/g",
        input);
  }

  private String replaceMetaChars(final String inputParam) {
    String input = inputParam;

    input = replace("(", "\\(", input);
    input = replace(")", "\\)", input);
    input = replace("[", "\\[", input);
    input = replace("]", "\\]", input);
    input = replace("{", "\\{", input);
    input = replace("}", "\\}", input);
    input = replace("#", "\\#", input);
    input = replace("/", "\\/", input);
    return input;
  }

}
