/*
 * 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.geode.internal.logging;

import static java.lang.System.lineSeparator;
import static org.apache.geode.logging.internal.spi.LogWriterLevel.ALL;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.Date;
import java.util.StringTokenizer;

import org.apache.geode.LogWriter;
import org.apache.geode.internal.ExitCode;

/**
 * Parses a log file written by a {@link LogWriter} into {@link LogFileParser.LogEntry}s. It
 * behaves sort of like an {@link StringTokenizer}.
 *
 * @since GemFire 3.0
 */
public class LogFileParser {

  private static final boolean TRIM_TIMESTAMPS = Boolean.getBoolean("mergelogs.TRIM_TIMESTAMPS");

  private static final boolean NEWLINE_AFTER_HEADER =
      Boolean.getBoolean("mergelogs.NEWLINE_AFTER_HEADER");

  private static final boolean TRIM_NAMES = Boolean.getBoolean("mergelogs.TRIM_NAMES");

  /** Text that signifies the start of a JRockit-style thread dump */
  private static final String FULL_THREAD_DUMP = "===== FULL THREAD DUMP ===============";

  /** The name of the log file being parsed */
  private final String logFileName;

  /** The name of the log file plus a colon and space */
  private final String extLogFileName;

  /** The buffer to read the log file from */
  private final BufferedReader br;

  /** Are there more entries to parser? */
  private boolean hasMoreEntries;

  /** The timestamp of the entry being parsed */
  private String timestamp;

  /** StringBuilder containing the text of the entry we're parsing */
  private StringBuilder sb;

  /** whether we're still reading the first line of the first entry */
  private boolean firstEntry = true;

  /**
   * StringBuilder containing white space that is the same length as logFileName plus ": ", in a
   * monospace font when tabs are 8 chars long
   */
  private final StringBuilder whiteFileName;

  /** whether to suppress blank lines in output */
  private final boolean suppressBlanks;

  /**
   * Creates a new {@code LogFileParser} that reads a log from a given
   * {@code BufferedReader}. Blanks are not suppressed, and non-timestamped lines are emitted
   * as-is.
   *
   * @param logFileName The name of the log file being parsed. This is appended to the entry. If
   *        {@code logFileName} is {@code null} nothing will be appended.
   * @param br Where to read the log from
   */
  public LogFileParser(final String logFileName, final BufferedReader br) {
    this(logFileName, br, false, false);
  }

  /**
   * Creates a new {@code LogFileParser} that reads a log from a given
   * {@code BufferedReader}.
   *
   * @param logFileName The name of the log file being parsed. This is appended to the entry. If
   *        {@code logFileName} is {@code null} nothing will be appended.
   * @param br Where to read the log from
   * @param tabOut Whether to add white-space to non-timestamped lines to align them with lines
   *        containing file names.
   * @param suppressBlanks whether to suppress blank lines
   */
  public LogFileParser(final String logFileName, final BufferedReader br, final boolean tabOut,
      final boolean suppressBlanks) {
    this.logFileName = logFileName;
    this.br = br;
    hasMoreEntries = true;
    timestamp = null;
    sb = new StringBuilder();
    this.suppressBlanks = suppressBlanks;
    whiteFileName = new StringBuilder();
    if (tabOut) {
      int numTabs = (logFileName.length() + 2) / 8;
      for (int i = 0; i < numTabs; i++) {
        whiteFileName.append('\t');
      }
      for (int i = (logFileName.length() + 2) % 8; i > 0; i--) {
        whiteFileName.append(' ');
      }
    }
    if (this.logFileName != null) {
      extLogFileName = this.logFileName + ": ";
    } else {
      extLogFileName = null;
    }
  }

  /**
   * Returns whether or not there are any more entries in the file to be parser.
   */
  public boolean hasMoreEntries() {
    return hasMoreEntries;
  }

  /**
   * copy the timestamp out of a log entry, if there is one, and return it. if there isn't a
   * timestamp, return null
   */
  private String getTimestamp(final String line) {
    int llen = line.length();
    String result = null;
    if (llen > 10) {
      // first see if the start of the line is a timestamp, as in a thread-dump's stamp
      if (line.charAt(0) == '2' && line.charAt(1) == '0' && line.charAt(4) == '-'
          && line.charAt(7) == '-') {
        return line.substring(0, 19).replace('-', '/');
      }
      // now look for gemfire's log format
      if (line.charAt(0) == '[') {
        if (line.charAt(1) == 'i' && line.charAt(2) == 'n'
            && line.charAt(3) == 'f' ||

            line.charAt(1) == 'f' && line.charAt(2) == 'i'
                && line.charAt(3) == 'n'
            ||

            line.charAt(1) == 'w' && line.charAt(2) == 'a'
                && line.charAt(3) == 'r'
            ||

            line.charAt(1) == 'd' && line.charAt(2) == 'e'
                && line.charAt(3) == 'b'
            ||

            line.charAt(1) == 't' && line.charAt(2) == 'r'
                && line.charAt(3) == 'a'
            ||

            line.charAt(1) == 's' && line.charAt(2) == 'e'
                && line.charAt(3) == 'v'
            ||

            line.charAt(1) == 'c' && line.charAt(2) == 'o'
                && line.charAt(3) == 'n'
            ||

            line.charAt(1) == 'e' && line.charAt(2) == 'r'
                && line.charAt(3) == 'r'
            ||

            line.charAt(1) == 's' && line.charAt(2) == 'e' && line.charAt(3) == 'c'
                && line.charAt(4) == 'u' && line.charAt(5) == 'r') {
          int sidx = 4;
          while (sidx < llen && line.charAt(sidx) != ' ') {
            sidx++;
          }
          int endIdx = sidx + 24;
          if (endIdx < llen) {
            result = line.substring(sidx + 1, endIdx + 1);
          }
        }
      }
    }
    return result;
  }

  /**
   * Returns the next entry in the log file. The last entry will be an instance of
   * {@link LogFileParser.LastLogEntry}.
   */
  public LogEntry getNextEntry() throws IOException {
    LogEntry entry = null;

    while (br.ready()) {
      String lineStr = br.readLine();
      if (lineStr == null) {
        break;
      }
      int llen = lineStr.length();
      int lend = llen;
      if (suppressBlanks || firstEntry) {
        // trim the end of the line
        while (lend > 1 && Character.isWhitespace(lineStr.charAt(lend - 1))) {
          lend--;
        }
        if (lend == 0) {
          continue;
        }
      }

      StringBuilder line = new StringBuilder(lineStr);
      if (lend != llen) {
        line.setLength(lend);
        llen = lend;
      }

      // Matcher matcher = pattern.matcher(line);
      String nextTimestamp = getTimestamp(lineStr);

      // See if we've found the beginning of a new log entry. If so, bundle
      // up the current string buffer and return it in a LogEntry representing
      // the currently parsed text
      if (nextTimestamp != null) {

        if (timestamp != null && TRIM_TIMESTAMPS) {
          int tsl = timestamp.length();
          if (tsl > 0) {
            // find where the year/mo/dy starts and delete it and the time zone
            int start = 5;
            if (line.charAt(start) != ' ') // info & fine
            {
              if (line.charAt(++start) != ' ') // finer & error
              {
                if (line.charAt(++start) != ' ') // finest, severe, config
                {
                  if (line.charAt(++start) != ' ') // warning
                  {
                    start = 0;
                  }
                }
              }
            }
            if (start > 0) {
              line.delete(start + 25, start + 29); // time zone
              line.delete(start, start + 11); // date
              if (TRIM_NAMES) {
                int idx2 = line.indexOf("<", +12);
                if (idx2 > start + 13) {
                  line.delete(start + 13, idx2 - 1);
                }
              }
            }
          }
          if (NEWLINE_AFTER_HEADER) {
            int idx = line.indexOf("tid=");
            if (idx > 0) {
              idx = line.indexOf("]", idx + 4);
              if (idx + 1 < line.length()) {
                line.insert(idx + 1, lineSeparator() + " ");
              }
            }
          }
        }

        if (timestamp != null) {
          entry = new LogEntry(timestamp, sb.toString(), suppressBlanks);
        }

        timestamp = nextTimestamp;

        if (!firstEntry) {
          sb = new StringBuilder(500);
        } else {
          firstEntry = false;
        }
        if (extLogFileName != null) {
          sb.append(extLogFileName);
        }

      } else if (line.indexOf(FULL_THREAD_DUMP) != -1) {
        // JRockit-style thread dumps have time stamps!
        String dump = lineStr;
        lineStr = br.readLine();
        if (lineStr == null) {
          break;
        }
        DateFormat df = DateFormatter.createDateFormat("E MMM d HH:mm:ss yyyy");
        df.setLenient(true);
        try {
          Date date = df.parse(lineStr);

          if (timestamp != null) {
            // We've found the end of a log entry
            entry = new LogEntry(timestamp, sb.toString());
          }

          df = DateFormatter.createDateFormat();
          timestamp = df.format(date);
          lineStr = dump;

          sb = new StringBuilder();
          if (extLogFileName != null) {
            sb.append(extLogFileName);
          }
          sb.append("[dump ");
          sb.append(timestamp);
          sb.append("]").append(lineSeparator()).append(lineSeparator());

        } catch (ParseException ex) {
          // Oh well...
          sb.append(dump);
        }
      } else {
        sb.append(whiteFileName);
      }

      sb.append(line);
      sb.append(lineSeparator());

      if (entry != null) {
        return entry;
      }
    }

    if (timestamp == null) {
      // The file didn't contain any log entries. Just use the
      // current time
      DateFormat df = DateFormatter.createDateFormat();
      // Date now = new Date();
      timestamp = df.format(new Date());

      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw, true);

      LocalLogWriter tempLogger = new LocalLogWriter(ALL.intLevel(), pw);
      tempLogger.info("MISSING TIME STAMP");
      pw.flush();
      sb.insert(0, lineSeparator() + lineSeparator());
      sb.insert(0, sw.toString().trim());
      sb.insert(0, extLogFileName);
    }

    // Place the final log entry
    entry = new LastLogEntry(timestamp, sb.toString());
    sb = null;
    hasMoreEntries = false;
    return entry;
  }

  /**
   * Main program that simply parses a log file and prints out the entries. It is used for testing
   * purposes.
   */
  public static void main(final String[] args) throws Exception {
    if (args.length < 1) {
      System.err.println("** Missing log file name");
      ExitCode.FATAL.doSystemExit();
    }

    String logFileName = args[0];
    try (FileReader fileReader = new FileReader(logFileName);
        BufferedReader br = new BufferedReader(fileReader)) {
      LogFileParser parser = new LogFileParser(logFileName, br, false, false);
      PrintWriter pw = new PrintWriter(System.out);
      while (parser.hasMoreEntries()) {
        LogEntry entry = parser.getNextEntry();
        entry.writeTo(pw);
      }
    }
  }

  /**
   * A parsed entry in a log file. Note that we maintain the entry's timestamp as a
   * {@code String}. {@link DateFormat#parse(String) Parsing} it was too
   * expensive.
   */
  static class LogEntry {

    /** Timestamp of the log entry */
    private final String timestamp;

    /** The contents of the log entry */
    private final String contents;

    /** whether extraneous blank lines are being suppressed */
    private boolean suppressBlanks;

    /**
     * Creates a new log entry with the given timestamp and contents
     */
    public LogEntry(final String timestamp, final String contents) {
      this.timestamp = timestamp;
      this.contents = contents;
    }

    /**
     * Creates a new log entry with the given timestamp and contents
     */
    public LogEntry(final String timestamp, final String contents, final boolean suppressBlanks) {
      this.timestamp = timestamp;
      this.contents = contents.trim();
      this.suppressBlanks = suppressBlanks;
    }

    /**
     * Returns the timestamp of this log entry
     */
    public String getTimestamp() {
      return timestamp;
    }

    /**
     * Returns the contents of this log entry
     *
     * @see #writeTo
     */
    String getContents() {
      return contents;
    }

    /**
     * Writes the contents of this log entry to a {@code PrintWriter}.
     */
    public void writeTo(final PrintWriter pw) {
      pw.println(contents);
      if (!suppressBlanks) {
        pw.println("");
      }
      pw.flush();
    }

    /**
     * Is this entry the last log entry?
     */
    public boolean isLast() {
      return false;
    }
  }

  /**
   * The last log entry read from a log file. We use a separate class to avoid the overhead of an
   * extra {@code boolean} field in each {@link LogFileParser.LogEntry}.
   */
  static class LastLogEntry extends LogEntry {

    public LastLogEntry(final String timestamp, final String contents) {
      super(timestamp, contents);
    }

    @Override
    public boolean isLast() {
      return true;
    }
  }
}
