/*
 * 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.log4j.varia;

import org.apache.log4j.Level;
import org.apache.log4j.Logger;
import org.apache.log4j.helpers.Constants;
import org.apache.log4j.plugins.Receiver;
import org.apache.log4j.rule.ExpressionRule;
import org.apache.log4j.rule.Rule;
import org.apache.log4j.spi.LocationInfo;
import org.apache.log4j.spi.LoggingEvent;
import org.apache.log4j.spi.ThrowableInformation;

import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

/**
 * 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 java.util.regex 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, using patterns from {@link java.text.SimpleDateFormat})<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)
 * - 'group' property can be set to associate multiple log file receivers
 * <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>
 * (NL)<br>
 * <p>
 * (NL) represents a new line embedded in the log format, supporting log formats whose fields span multiple lines
 * <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 the following 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="PROP(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 - &lt;test&gt;   &lt;test2&gt;something here&lt;/test2&gt;   &lt;test3 blah=something/&gt;   &lt;test4&gt;       &lt;test5&gt;something else&lt;/test5&gt;   &lt;/test4&gt;&lt;/test&gt;<br>
 * 880    [       Thread-2] DEBUG                   first.logger third - &lt;test&gt;   &lt;test2&gt;something here&lt;/test2&gt;   &lt;test3 blah=something/&gt;   &lt;test4&gt;       &lt;test5&gt;something else&lt;/test5&gt;   &lt;/test4&gt;&lt;/test&gt;<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 extends Receiver {
    private final List<String> keywords = new ArrayList<>();

    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 NEWLINE = "(NL)";

    private static final String DEFAULT_HOST = "file";

    //all lines other than first line of exception begin with tab followed by 'at' followed by text
    private static final String EXCEPTION_PATTERN = "^\\s+at.*";
    private static final String REGEXP_DEFAULT_WILDCARD = ".*?";
    private static final String REGEXP_GREEDY_WILDCARD = ".*";
    private static final String PATTERN_WILDCARD = "*";
    //pull in optional leading and trailing spaces
    private static final String NOSPACE_GROUP = "(\\s*?\\S*?\\s*?)";
    private static final String DEFAULT_GROUP = "(" + REGEXP_DEFAULT_WILDCARD + ")";
    private static final String GREEDY_GROUP = "(" + REGEXP_GREEDY_WILDCARD + ")";
    private static final String MULTIPLE_SPACES_REGEXP = "[ ]+";
    private static final String NEWLINE_REGEXP = "\n";
    private final String newLine = System.getProperty("line.separator");

    private final String[] emptyException = new String[]{""};

    private SimpleDateFormat dateFormat;
    private String timestampFormat;
    private String logFormat;
    private String customLevelDefinitions;
    private String fileURL;
    private String host;
    private String path;
    private boolean tailing;
    private String filterExpression;
    private long waitMillis = 2000; //default 2 seconds
    private String group;

    private static final String VALID_DATEFORMAT_CHARS = "GyYMwWDdFEuaHkKhmsSzZX";
    private static final String VALID_DATEFORMAT_CHAR_PATTERN = "[" + VALID_DATEFORMAT_CHARS + "]";

    private Rule expressionRule;

    private Map currentMap;
    private List<String> additionalLines;
    private List<String> matchingKeywords;

    private String regexp;
    private Reader reader;
    private Pattern regexpPattern;
    private Pattern exceptionPattern;
    private String timestampPatternText;

    private boolean useCurrentThread;
    public static final int MISSING_FILE_RETRY_MILLIS = 10000;
    private boolean appendNonMatches;
    private final Map<String, Level> customLevelDefinitionMap = new HashMap<>();

    //default to one line - this number is incremented for each (NL) found in the logFormat
    private int lineCount = 1;

    public LogFilePatternReceiver() {
        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);
        try {
            exceptionPattern = Pattern.compile(EXCEPTION_PATTERN);
        } catch (PatternSyntaxException pse) {
            //shouldn't happen
        }
    }

    /**
     * Accessor
     *
     * @return file URL
     */
    public String getFileURL() {
        return fileURL;
    }

    /**
     * Mutator
     *
     * @param fileURL
     */
    public void setFileURL(String fileURL) {
        this.fileURL = fileURL;
    }

    /**
     * If the log file contains non-log4j level strings, they can be mapped to log4j levels using the format (android example):
     * V=TRACE,D=DEBUG,I=INFO,W=WARN,E=ERROR,F=FATAL,S=OFF
     *
     * @param customLevelDefinitions the level definition string
     */
    public void setCustomLevelDefinitions(String customLevelDefinitions) {
        this.customLevelDefinitions = customLevelDefinitions;
    }

    public String getCustomLevelDefinitions() {
        return customLevelDefinitions;
    }

    /**
     * Accessor
     *
     * @return append non matches
     */
    public boolean isAppendNonMatches() {
        return appendNonMatches;
    }

    /**
     * Mutator
     *
     * @param appendNonMatches
     */
    public void setAppendNonMatches(boolean appendNonMatches) {
        this.appendNonMatches = appendNonMatches;
    }

    /**
     * Accessor
     *
     * @return filter expression
     */
    public String getFilterExpression() {
        return filterExpression;
    }

    /**
     * Mutator
     *
     * @param filterExpression
     */
    public void setFilterExpression(String filterExpression) {
        this.filterExpression = filterExpression;
    }

    /**
     * Accessor
     *
     * @return tailing
     */
    public boolean isTailing() {
        return tailing;
    }

    /**
     * Mutator
     *
     * @param tailing
     */
    public void setTailing(boolean tailing) {
        this.tailing = tailing;
    }

    /**
     * When true, this property uses the current Thread to perform the import,
     * otherwise when false (the default), a new Thread is created and started to manage
     * the import.
     *
     * @return
     */
    public final boolean isUseCurrentThread() {
        return useCurrentThread;
    }

    /**
     * Sets whether the current Thread or a new Thread is created to perform the import,
     * the default being false (new Thread created).
     *
     * @param useCurrentThread
     */
    public final void setUseCurrentThread(boolean useCurrentThread) {
        this.useCurrentThread = useCurrentThread;
    }

    /**
     * Accessor
     *
     * @return log format
     */
    public String getLogFormat() {
        return logFormat;
    }

    /**
     * Mutator
     *
     * @param logFormat the format
     */
    public void setLogFormat(String logFormat) {
        this.logFormat = logFormat;
    }

    /**
     * Mutator
     */
    public void setGroup(String group) {
        this.group = group;
    }


    /**
     * Accessor
     *
     * @return group
     */
    public String getGroup() {
        return group;
    }

    /**
     * Mutator.  Specify a pattern from {@link java.text.SimpleDateFormat}
     *
     * @param timestampFormat
     */
    public void setTimestampFormat(String timestampFormat) {
        this.timestampFormat = timestampFormat;
    }

    /**
     * Accessor
     *
     * @return timestamp format
     */
    public String getTimestampFormat() {
        return timestampFormat;
    }

    /**
     * Accessor
     *
     * @return millis between retrieves of content
     */
    public long getWaitMillis() {
        return waitMillis;
    }

    /**
     * Mutator
     *
     * @param waitMillis
     */
    public void setWaitMillis(long waitMillis) {
        this.waitMillis = waitMillis;
    }

    /**
     * Walk the additionalLines list, looking for the EXCEPTION_PATTERN.
     * <p>
     * Return the index of the first matched line
     * (the match may be the 1st line of an exception)
     * <p>
     * Assumptions: <br>
     * - the additionalLines list may contain both message and exception lines<br>
     * - message lines are added to the additionalLines list and then
     * exception lines (all message lines occur in the list prior to all
     * exception lines)
     *
     * @return -1 if no exception line exists, line number otherwise
     */
    private int getExceptionLine() {
        for (int i = 0; i < additionalLines.size(); i++) {
            Matcher exceptionMatcher = exceptionPattern.matcher(additionalLines.get(i));
            if (exceptionMatcher.matches()) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Combine all message lines occuring in the additionalLines list, adding
     * a newline character between each line
     * <p>
     * the event will already have a message - combine this message
     * with the message lines in the additionalLines list
     * (all entries prior to the exceptionLine index)
     *
     * @param firstMessageLine primary message line
     * @param exceptionLine    index of first exception line
     * @return message
     */
    private String buildMessage(String firstMessageLine, int exceptionLine) {
        if (additionalLines.size() == 0) {
            return firstMessageLine;
        }
        StringBuilder message = new StringBuilder();
        if (firstMessageLine != null) {
            message.append(firstMessageLine);
        }

        int linesToProcess = (exceptionLine == -1 ? additionalLines.size() : exceptionLine);

        for (int i = 0; i < linesToProcess; i++) {
            message.append(newLine);
            message.append(additionalLines.get(i));
        }
        return message.toString();
    }

    /**
     * Combine all exception lines occuring in the additionalLines list into a
     * String array
     * <p>
     * (all entries equal to or greater than the exceptionLine index)
     *
     * @param exceptionLine index of first exception line
     * @return exception
     */
    private String[] buildException(int exceptionLine) {
        if (exceptionLine == -1) {
            return emptyException;
        }
        String[] exception = new String[additionalLines.size() - exceptionLine - 1];
        for (int i = 0; i < exception.length; i++) {
            exception[i] = additionalLines.get(i + exceptionLine);
        }
        return exception;
    }

    /**
     * Construct a logging event from currentMap and additionalLines
     * (additionalLines contains multiple message lines and any exception lines)
     * <p>
     * CurrentMap and additionalLines are cleared in the process
     *
     * @return event
     */
    private LoggingEvent buildEvent() {
        if (currentMap.size() == 0) {
            if (additionalLines.size() > 0) {
                for (Object additionalLine : additionalLines) {
                    getLogger().debug("found non-matching line: " + additionalLine);
                }
            }
            additionalLines.clear();
            return null;
        }
        //the current map contains fields - build an event
        int exceptionLine = getExceptionLine();
        String[] exception = buildException(exceptionLine);

        //messages are listed before exceptions in additionallines
        if (additionalLines.size() > 0 && exception.length > 0) {
            currentMap.put(MESSAGE, buildMessage((String) currentMap.get(MESSAGE),
                exceptionLine));
        }
        LoggingEvent event = convertToEvent(currentMap, exception);
        currentMap.clear();
        additionalLines.clear();
        return event;
    }

    /**
     * Read, parse and optionally tail the log file, converting entries into logging events.
     * <p>
     * A runtimeException is thrown if the logFormat pattern is malformed.
     *
     * @param bufferedReader
     * @throws IOException
     */
    protected void process(BufferedReader bufferedReader) throws IOException {
        Matcher eventMatcher;
        Matcher exceptionMatcher;
        String readLine;
        //if newlines are provided in the logFormat - (NL) - combine the lines prior to matching
        while ((readLine = bufferedReader.readLine()) != null) {
            StringBuilder line = new StringBuilder(readLine);
            //there is already one line (read above, start i at 1
            for (int i = 1; i < lineCount; i++) {
                String thisLine = bufferedReader.readLine();
                if (thisLine != null) {
                    line.append(newLine).append(thisLine);
                }
            }
            String input = line.toString();
            eventMatcher = regexpPattern.matcher(input);
            //skip empty line entries
            if (input.trim().equals("")) {
                continue;
            }
            exceptionMatcher = exceptionPattern.matcher(input);
            if (eventMatcher.matches()) {
                //build an event from the previous match (held in current map)
                LoggingEvent event = buildEvent();
                if (event != null) {
                    if (passesExpression(event)) {
                        doPost(event);
                    }
                }
                currentMap.putAll(processEvent(eventMatcher.toMatchResult()));
            } else if (exceptionMatcher.matches()) {
                //an exception line
                additionalLines.add(input);
            } else {
                //neither...either post an event with the line or append as additional lines
                //if this was a logging event with multiple lines, each line will show up as its own event instead of being
                //appended as multiple lines on the same event..
                //choice is to have each non-matching line show up as its own line, or append them all to a previous event
                if (appendNonMatches) {
                    //hold on to the previous time, so we can do our best to preserve time-based ordering if the event is a non-match
                    String lastTime = (String) currentMap.get(TIMESTAMP);
                    //build an event from the previous match (held in current map)
                    if (currentMap.size() > 0) {
                        LoggingEvent event = buildEvent();
                        if (event != null) {
                            if (passesExpression(event)) {
                                doPost(event);
                            }
                        }
                    }
                    if (lastTime != null) {
                        currentMap.put(TIMESTAMP, lastTime);
                    }
                    currentMap.put(MESSAGE, input);
                } else {
                    additionalLines.add(input);
                }
            }
        }

        //process last event if one exists
        LoggingEvent event = buildEvent();
        if (event != null) {
            if (passesExpression(event)) {
                doPost(event);
            }
        }
    }

    protected void createPattern() {
        regexpPattern = Pattern.compile(regexp);
    }

    /**
     * Helper method that supports the evaluation of the expression
     *
     * @param event
     * @return true if expression isn't set, or the result of the evaluation otherwise
     */
    private boolean passesExpression(LoggingEvent event) {
        if (event != null) {
            if (expressionRule != null) {
                return (expressionRule.evaluate(event, null));
            }
        }
        return true;
    }

    /**
     * Convert the match into a map.
     * <p>
     * Relies on the fact that the matchingKeywords list is in the same
     * order as the groups in the regular expression
     *
     * @param result
     * @return map
     */
    private Map processEvent(MatchResult result) {
        Map map = new HashMap();
        //group zero is the entire match - process all other groups
        for (int i = 1; i < result.groupCount() + 1; i++) {
            Object key = matchingKeywords.get(i - 1);
            Object value = result.group(i);
            map.put(key, value);

        }
        return map;
    }

    /**
     * Helper method that will convert timestamp format to a pattern
     *
     * @return string
     */
    private String convertTimestamp() {
        //some locales (for example, French) generate timestamp text with characters not included in \w -
        // now using \S (all non-whitespace characters) instead of /w
        String result = "";
        if (timestampFormat != null) {
            result = timestampFormat.replaceAll(Pattern.quote("+"), "[+]");
            result = result.replaceAll(VALID_DATEFORMAT_CHAR_PATTERN, "\\\\S+");
            //make sure dots in timestamp are escaped
            result = result.replaceAll(Pattern.quote("."), "\\\\.");
        }
        return result;
    }

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

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

    public String getPath() {
        return path;
    }

    /**
     * Build the regular expression needed to parse log entries
     */
    protected void initialize() {
        if (host == null && path == null) {
            try {
                URL url = new URL(fileURL);
                host = url.getHost();
                path = url.getPath();
            } catch (MalformedURLException e1) {
                // TODO Auto-generated catch block
                e1.printStackTrace();
            }
        }
        if (host == null || host.trim().equals("")) {
            host = DEFAULT_HOST;
        }
        if (path == null || path.trim().equals("")) {
            path = fileURL;
        }

        currentMap = new HashMap();
        additionalLines = new ArrayList<>();
        matchingKeywords = new ArrayList<>();

        if (timestampFormat != null) {
            dateFormat = new SimpleDateFormat(quoteTimeStampChars(timestampFormat));
            timestampPatternText = convertTimestamp();
        }
        //if custom level definitions exist, parse them
        updateCustomLevelDefinitionMap();
        try {
            if (filterExpression != null) {
                expressionRule = ExpressionRule.getRule(filterExpression);
            }
        } catch (Exception e) {
            getLogger().warn("Invalid filter expression: " + filterExpression, e);
        }

        List<String> buildingKeywords = new ArrayList<>();

        String newPattern = logFormat;

        //process newlines - (NL) - in the logFormat - before processing properties
        int index = 0;
        while (index > -1) {
            index = newPattern.indexOf(NEWLINE);
            if (index > -1) {
                //keep track of number of expected newlines in the format, so the lines can be concatenated prior to matching
                lineCount++;
                newPattern = singleReplace(newPattern, NEWLINE, NEWLINE_REGEXP);
            }
        }

        String current = newPattern;
        //build a list of property names and temporarily replace the property with an empty string,
        //we'll rebuild the pattern later
        List<String> propertyNames = new ArrayList<>();
        index = 0;
        while (index > -1) {
            if (current.contains(PROP_START) && current.contains(PROP_END)) {
                index = current.indexOf(PROP_START);
                String longPropertyName = current.substring(current.indexOf(PROP_START), current.indexOf(PROP_END) + 1);
                String shortProp = getShortPropertyName(longPropertyName);
                buildingKeywords.add(shortProp);
                propertyNames.add(longPropertyName);
                current = current.substring(longPropertyName.length() + 1 + index);
                newPattern = singleReplace(newPattern, longPropertyName, Integer.toString(buildingKeywords.size() - 1));
            } else {
                //no properties
                index = -1;
            }
        }

        /*
         * we're using a treemap, so the index will be used as the key to ensure
         * keywords are ordered correctly
         *
         * examine pattern, adding keywords to an index-based map patterns can
         * contain only one of these per entry...properties are the only 'keyword'
         * that can occur multiple times in an entry
         */
        for (Object keyword1 : keywords) {
            String keyword = (String) keyword1;
            int index2 = newPattern.indexOf(keyword);
            if (index2 > -1) {
                buildingKeywords.add(keyword);
                newPattern = singleReplace(newPattern, keyword, Integer.toString(buildingKeywords.size() - 1));
            }
        }

        StringBuilder buildingInt = new StringBuilder();

        for (int i = 0; i < newPattern.length(); i++) {
            String thisValue = String.valueOf(newPattern.substring(i, i + 1));
            if (isInteger(thisValue)) {
                buildingInt.append(thisValue);
            } else {
                String stringInt = buildingInt.toString();
                if (isInteger(stringInt)) {
                    matchingKeywords.add(buildingKeywords.get(Integer.parseInt(stringInt)));
                }
                //reset
                buildingInt.setLength(0);
            }
        }

        //if the very last value is an int, make sure to add it
        String stringInt = buildingInt.toString();
        if (isInteger(stringInt)) {
            matchingKeywords.add(buildingKeywords.get(Integer.parseInt(stringInt)));
        }

        newPattern = replaceMetaChars(newPattern);

        //compress one or more spaces in the pattern into the [ ]+ regexp
        //(supports padding of level in log files)
        newPattern = newPattern.replaceAll(MULTIPLE_SPACES_REGEXP, MULTIPLE_SPACES_REGEXP);
        newPattern = newPattern.replaceAll(Pattern.quote(PATTERN_WILDCARD), REGEXP_DEFAULT_WILDCARD);
        //use buildingKeywords here to ensure correct order
        for (int i = 0; i < buildingKeywords.size(); i++) {
            String keyword = buildingKeywords.get(i);
            //make the final keyword greedy (we're assuming it's the message)
            if (i == (buildingKeywords.size() - 1)) {
                newPattern = singleReplace(newPattern, String.valueOf(i), GREEDY_GROUP);
            } else if (TIMESTAMP.equals(keyword)) {
                newPattern = singleReplace(newPattern, String.valueOf(i), "(" + timestampPatternText + ")");
            } else if (LOGGER.equals(keyword) || LEVEL.equals(keyword)) {
                newPattern = singleReplace(newPattern, String.valueOf(i), NOSPACE_GROUP);
            } else {
                newPattern = singleReplace(newPattern, String.valueOf(i), DEFAULT_GROUP);
            }
        }

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

    private void updateCustomLevelDefinitionMap() {
        if (customLevelDefinitions != null) {
            StringTokenizer entryTokenizer = new StringTokenizer(customLevelDefinitions, ",");

            customLevelDefinitionMap.clear();
            while (entryTokenizer.hasMoreTokens()) {
                StringTokenizer innerTokenizer = new StringTokenizer(entryTokenizer.nextToken(), "=");
                customLevelDefinitionMap.put(innerTokenizer.nextToken(), Level.toLevel(innerTokenizer.nextToken()));
            }
        }
    }

    private boolean isInteger(String value) {
        try {
            Integer.parseInt(value);
            return true;
        } catch (NumberFormatException nfe) {
            return false;
        }
    }

    private String quoteTimeStampChars(String input) {
        //put single quotes around text that isn't a supported dateformat char
        StringBuilder result = new StringBuilder();
        //ok to default to false because we also check for index zero below
        boolean lastCharIsDateFormat = false;
        for (int i = 0; i < input.length(); i++) {
            String thisVal = input.substring(i, i + 1);
            boolean thisCharIsDateFormat = VALID_DATEFORMAT_CHARS.contains(thisVal);
            //we have encountered a non-dateformat char
            if (!thisCharIsDateFormat && (i == 0 || lastCharIsDateFormat)) {
                result.append("'");
            }
            //we have encountered a dateformat char after previously encountering a non-dateformat char
            if (thisCharIsDateFormat && i > 0 && !lastCharIsDateFormat) {
                result.append("'");
            }
            lastCharIsDateFormat = thisCharIsDateFormat;
            result.append(thisVal);
        }
        //append an end single-quote if we ended with non-dateformat char
        if (!lastCharIsDateFormat) {
            result.append("'");
        }
        return result.toString();
    }

    private String singleReplace(String inputString, String oldString, String newString) {
        int propLength = oldString.length();
        int startPos = inputString.indexOf(oldString);
        if (startPos == -1) {
            getLogger().info("string: " + oldString + " not found in input: " + inputString + " - returning input");
            return inputString;
        }
        if (startPos == 0) {
            inputString = inputString.substring(propLength);
            inputString = newString + inputString;
        } else {
            inputString = inputString.substring(0, startPos) + newString + inputString.substring(startPos + propLength);
        }
        return inputString;
    }

    private String getShortPropertyName(String longPropertyName) {
        String currentProp = longPropertyName.substring(longPropertyName.indexOf(PROP_START));
        String prop = currentProp.substring(0, currentProp.indexOf(PROP_END) + 1);
        String shortProp = prop.substring(PROP_START.length(), prop.length() - 1);
        return shortProp;
    }

    /**
     * Some perl5 characters may occur in the log file format.
     * Escape these characters to prevent parsing errors.
     *
     * @param input
     * @return string
     */
    private String replaceMetaChars(String input) {
        //escape backslash first since that character is used to escape the remaining meta chars
        input = input.replaceAll("\\\\", "\\\\\\");

        //don't escape star - it's used as the wildcard
        input = input.replaceAll(Pattern.quote("]"), "\\\\]");
        input = input.replaceAll(Pattern.quote("["), "\\\\[");
        input = input.replaceAll(Pattern.quote("^"), "\\\\^");
        input = input.replaceAll(Pattern.quote("$"), "\\\\$");
        input = input.replaceAll(Pattern.quote("."), "\\\\.");
        input = input.replaceAll(Pattern.quote("|"), "\\\\|");
        input = input.replaceAll(Pattern.quote("?"), "\\\\?");
        input = input.replaceAll(Pattern.quote("+"), "\\\\+");
        input = input.replaceAll(Pattern.quote("("), "\\\\(");
        input = input.replaceAll(Pattern.quote(")"), "\\\\)");
        input = input.replaceAll(Pattern.quote("-"), "\\\\-");
        input = input.replaceAll(Pattern.quote("{"), "\\\\{");
        input = input.replaceAll(Pattern.quote("}"), "\\\\}");
        input = input.replaceAll(Pattern.quote("#"), "\\\\#");
        return input;
    }

    /**
     * Convert a keyword-to-values map to a LoggingEvent
     *
     * @param fieldMap
     * @param exception
     * @return logging event
     */
    private LoggingEvent convertToEvent(Map fieldMap, String[] exception) {
        if (fieldMap == null) {
            return null;
        }

        //a logger must exist at a minimum for the event to be processed
        if (!fieldMap.containsKey(LOGGER)) {
            fieldMap.put(LOGGER, "Unknown");
        }
        if (exception == null) {
            exception = emptyException;
        }

        Logger logger;
        long timeStamp = 0L;
        String level;
        String threadName;
        Object message;
        String ndc;
        String className;
        String methodName;
        String eventFileName;
        String lineNumber;
        Hashtable properties = new Hashtable();

        logger = Logger.getLogger((String) fieldMap.remove(LOGGER));

        if ((dateFormat != null) && fieldMap.containsKey(TIMESTAMP)) {
            try {
                timeStamp = dateFormat.parse((String) fieldMap.remove(TIMESTAMP))
                    .getTime();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        //use current time if timestamp not parseable/dateformat not specified
        if (timeStamp == 0L) {
            timeStamp = System.currentTimeMillis();
        }

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

        level = (String) fieldMap.remove(LEVEL);
        Level levelImpl;
        if (level == null) {
            levelImpl = Level.DEBUG;
        } else {
            //first try to resolve against custom level definition map, then fall back to regular levels
            levelImpl = customLevelDefinitionMap.get(level);
            if (levelImpl == null) {
                levelImpl = Level.toLevel(level.trim());
                if (!level.equals(levelImpl.toString())) {
                    //check custom level map
                    if (levelImpl == null) {
                        levelImpl = Level.DEBUG;
                        getLogger().debug("found unexpected level: " + level + ", logger: " + logger.getName() + ", msg: " + message);
                        //make sure the text that couldn't match a level is added to the message
                        message = level + " " + message;
                    }
                }
            }
        }

        threadName = (String) fieldMap.remove(THREAD);

        ndc = (String) fieldMap.remove(NDC);

        className = (String) fieldMap.remove(CLASS);

        methodName = (String) fieldMap.remove(METHOD);

        eventFileName = (String) fieldMap.remove(FILE);

        lineNumber = (String) fieldMap.remove(LINE);

        properties.put(Constants.HOSTNAME_KEY, host);
        properties.put(Constants.APPLICATION_KEY, path);
        properties.put(Constants.RECEIVER_NAME_KEY, getName());
        if (group != null) {
            properties.put(Constants.GROUP_KEY, group);
        }

        //all remaining entries in fieldmap are properties
        properties.putAll(fieldMap);

        LocationInfo info;

        if ((eventFileName != null) || (className != null) || (methodName != null)
            || (lineNumber != null)) {
            info = new LocationInfo(eventFileName, className, methodName, lineNumber);
        } else {
            info = LocationInfo.NA_LOCATION_INFO;
        }

        LoggingEvent event = new LoggingEvent(null,
            logger, timeStamp, levelImpl, message,
            threadName,
            new ThrowableInformation(exception),
            ndc,
            info,
            properties);

        return event;
    }

//  public static void main(String[] args) {
//    org.apache.log4j.Logger rootLogger = org.apache.log4j.Logger.getRootLogger();
//    org.apache.log4j.ConsoleAppender appender = new org.apache.log4j.ConsoleAppender(new org.apache.log4j.SimpleLayout());
//    appender.setName("console");
//    rootLogger.addAppender(appender);
//    LogFilePatternReceiver test = new LogFilePatternReceiver();
//    org.apache.log4j.spi.LoggerRepository repo = new org.apache.log4j.LoggerRepositoryExImpl(org.apache.log4j.LogManager.getLoggerRepository());
//    test.setLoggerRepository(repo);
//    test.setLogFormat("PROP(RELATIVETIME) [THREAD] LEVEL LOGGER * - MESSAGE");
//    test.setTailing(false);
//    test.setAppendNonMatches(true);
//    test.setTimestampFormat("yyyy-MM-d HH:mm:ss,SSS");
//    test.setFileURL("file:///C:/log/test.log");
//    test.activateOptions();
//  }

    /**
     * Close the reader.
     */
    public void shutdown() {
        getLogger().info(getPath() + " shutdown");
        active = false;
        try {
            if (reader != null) {
                reader.close();
                reader = null;
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }

    /**
     * Read and process the log file.
     */
    public void activateOptions() {
        getLogger().info("activateOptions");
        active = true;
        Runnable runnable = new Runnable() {
            public void run() {
                initialize();
                while (reader == null) {
                    getLogger().info("attempting to load file: " + getFileURL());
                    try {
                        reader = new InputStreamReader(new URL(getFileURL()).openStream(), "UTF-8");
                    } catch (FileNotFoundException fnfe) {
                        getLogger().info("file not available - will try again");
                        synchronized (this) {
                            try {
                                wait(MISSING_FILE_RETRY_MILLIS);
                            } catch (InterruptedException ie) {
                            }
                        }
                    } catch (IOException ioe) {
                        getLogger().warn("unable to load file", ioe);
                        return;
                    }
                }
                try {
                    BufferedReader bufferedReader = new BufferedReader(reader);
                    createPattern();
                    do {
                        process(bufferedReader);
                        try {
                            synchronized (this) {
                                wait(waitMillis);
                            }
                        } catch (InterruptedException ie) {
                        }
                        if (tailing) {
                            getLogger().debug("tailing file");
                        }
                    } while (tailing);

                } catch (IOException ioe) {
                    //io exception - probably shut down
                    getLogger().info("stream closed");
                }
                getLogger().debug("processing " + path + " complete");
                shutdown();
            }
        };
        if (useCurrentThread) {
            runnable.run();
        } else {
            new Thread(runnable, "LogFilePatternReceiver-" + getName()).start();
        }
    }
}
