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

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.TreeMap;

import org.apache.log4j.helpers.OptionConverter;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.Filter.Result;
import org.apache.logging.log4j.core.appender.ConsoleAppender;
import org.apache.logging.log4j.core.appender.FileAppender;
import org.apache.logging.log4j.core.appender.NullAppender;
import org.apache.logging.log4j.core.appender.RollingFileAppender;
import org.apache.logging.log4j.core.config.ConfigurationException;
import org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.ComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder;
import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
import org.apache.logging.log4j.core.config.builder.api.LayoutComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.LoggerComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.RootLoggerComponentBuilder;
import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration;
import org.apache.logging.log4j.status.StatusLogger;
import org.apache.logging.log4j.util.Strings;

/**
 * Experimental parser for Log4j 1.2 properties configuration files.
 *
 * This class is not thread-safe.
 *
 * <p>
 * From the Log4j 1.2 Javadocs:
 * </p>
 * <p>
 * All option values admit variable substitution. The syntax of variable substitution is similar to that of Unix shells. The string between
 * an opening "${" and closing "}" is interpreted as a key. The value of the substituted variable can be defined as a system property or in
 * the configuration file itself. The value of the key is first searched in the system properties, and if not found there, it is then
 * searched in the configuration file being parsed. The corresponding value replaces the ${variableName} sequence. For example, if java.home
 * system property is set to /home/xyz, then every occurrence of the sequence ${java.home} will be interpreted as /home/xyz.
 * </p>
 */
public class Log4j1ConfigurationParser {

    private static final String COMMA_DELIMITED_RE = "\\s*,\\s*";
    private static final String ROOTLOGGER = "rootLogger";
    private static final String ROOTCATEGORY = "rootCategory";
    private static final String TRUE = "true";
    private static final String FALSE = "false";
    private static final String RELATIVE = "RELATIVE";
    private static final String NULL = "NULL";

    private final Properties properties = new Properties();

    private final ConfigurationBuilder<BuiltConfiguration> builder = ConfigurationBuilderFactory
            .newConfigurationBuilder();

    /**
     * Parses a Log4j 1.2 properties configuration file in ISO 8859-1 encoding into a ConfigurationBuilder.
     *
     * @param input
     *            InputStream to read from is assumed to be ISO 8859-1, and will not be closed.
     * @return the populated ConfigurationBuilder, never {@code null}
     * @throws IOException
     *             if unable to read the input
     * @throws ConfigurationException
     *             if the input does not contain a valid configuration
     */
    public ConfigurationBuilder<BuiltConfiguration> buildConfigurationBuilder(final InputStream input)
            throws IOException {
        try {
            properties.load(input);
            final String rootCategoryValue = getLog4jValue(ROOTCATEGORY);
            final String rootLoggerValue = getLog4jValue(ROOTLOGGER);
            if (rootCategoryValue == null && rootLoggerValue == null) {
                // This is not a Log4j 1 properties configuration file.
                warn("Missing " + ROOTCATEGORY + " or " + ROOTLOGGER + " in " + input);
                // throw new ConfigurationException(
                // "Missing " + ROOTCATEGORY + " or " + ROOTLOGGER + " in " + input);
            }
            builder.setConfigurationName("Log4j1");
            // DEBUG
            final String debugValue = getLog4jValue("debug");
            if (Boolean.parseBoolean(debugValue)) {
                builder.setStatusLevel(Level.DEBUG);
            }
            // global threshold
            final String threshold = OptionConverter.findAndSubst(PropertiesConfiguration.THRESHOLD_KEY, properties);
            if (threshold != null) {
                final Level level = OptionConverter.convertLevel(threshold.trim(), Level.ALL);
                builder.add(builder.newFilter("ThresholdFilter", Result.NEUTRAL, Result.DENY)
                        .addAttribute("level", level));
            }
            // Root
            buildRootLogger(getLog4jValue(ROOTCATEGORY));
            buildRootLogger(getLog4jValue(ROOTLOGGER));
            // Appenders
            final Map<String, String> appenderNameToClassName = buildClassToPropertyPrefixMap();
            for (final Map.Entry<String, String> entry : appenderNameToClassName.entrySet()) {
                final String appenderName = entry.getKey();
                final String appenderClass = entry.getValue();
                buildAppender(appenderName, appenderClass);
            }
            // Loggers
            buildLoggers("log4j.category.");
            buildLoggers("log4j.logger.");
            buildProperties();
            return builder;
        } catch (final IllegalArgumentException e) {
            throw new ConfigurationException(e);
        }
    }

    private void buildProperties() {
        for (final Map.Entry<Object, Object> entry : new TreeMap<>(properties).entrySet()) {
            final String key = entry.getKey().toString();
            if (!key.startsWith("log4j.") && !key.equals(ROOTCATEGORY) && !key.equals(ROOTLOGGER)) {
                builder.addProperty(key, Objects.toString(entry.getValue(), Strings.EMPTY));
            }
        }
    }

    private void warn(final String string) {
        System.err.println(string);
    }

    private Map<String, String> buildClassToPropertyPrefixMap() {
        final String prefix = "log4j.appender.";
        final int preLength = prefix.length();
        final Map<String, String> map = new HashMap<>();
        for (final Map.Entry<Object, Object> entry : properties.entrySet()) {
            final Object keyObj = entry.getKey();
            if (keyObj != null) {
                final String key = keyObj.toString().trim();
                if (key.startsWith(prefix)) {
                    if (key.indexOf('.', preLength) < 0) {
                        final String name = key.substring(preLength);
                        final Object value = entry.getValue();
                        if (value != null) {
                            map.put(name, value.toString().trim());
                        }
                    }
                }
            }
        }
        return map;
    }

    private void buildAppender(final String appenderName, final String appenderClass) {
        switch (appenderClass) {
        case "org.apache.log4j.ConsoleAppender":
            buildConsoleAppender(appenderName);
            break;
        case "org.apache.log4j.FileAppender":
            buildFileAppender(appenderName);
            break;
        case "org.apache.log4j.DailyRollingFileAppender":
            buildDailyRollingFileAppender(appenderName);
            break;
        case "org.apache.log4j.RollingFileAppender":
            buildRollingFileAppender(appenderName);
            break;
        case "org.apache.log4j.varia.NullAppender":
            buildNullAppender(appenderName);
            break;
        default:
            reportWarning("Unknown appender class: " + appenderClass + "; ignoring appender: " + appenderName);
        }
    }

    private void buildConsoleAppender(final String appenderName) {
        final AppenderComponentBuilder appenderBuilder = builder.newAppender(appenderName, ConsoleAppender.PLUGIN_NAME);
        final String targetValue = getLog4jAppenderValue(appenderName, "Target", "System.out");
        if (targetValue != null) {
            final ConsoleAppender.Target target;
            switch (targetValue) {
            case "System.out":
                target = ConsoleAppender.Target.SYSTEM_OUT;
                break;
            case "System.err":
                target = ConsoleAppender.Target.SYSTEM_ERR;
                break;
            default:
                reportWarning("Unknown value for console Target: " + targetValue);
                target = null;
            }
            if (target != null) {
                appenderBuilder.addAttribute("target", target);
            }
        }
        buildAttribute(appenderName, appenderBuilder, "Follow", "follow");
        if (FALSE.equalsIgnoreCase(getLog4jAppenderValue(appenderName, "ImmediateFlush"))) {
            reportWarning("ImmediateFlush=false is not supported on Console appender");
        }
        buildAppenderLayout(appenderName, appenderBuilder);
        builder.add(appenderBuilder);
    }

    private void buildFileAppender(final String appenderName) {
        final AppenderComponentBuilder appenderBuilder = builder.newAppender(appenderName, FileAppender.PLUGIN_NAME);
        buildFileAppender(appenderName, appenderBuilder);
        builder.add(appenderBuilder);
    }

    private void buildFileAppender(final String appenderName, final AppenderComponentBuilder appenderBuilder) {
        buildMandatoryAttribute(appenderName, appenderBuilder, "File", "fileName");
        buildAttribute(appenderName, appenderBuilder, "Append", "append");
        buildAttribute(appenderName, appenderBuilder, "BufferedIO", "bufferedIo");
        buildAttribute(appenderName, appenderBuilder, "BufferSize", "bufferSize");
        buildAttribute(appenderName, appenderBuilder, "ImmediateFlush", "immediateFlush");
        buildAppenderLayout(appenderName, appenderBuilder);
    }

    private void buildDailyRollingFileAppender(final String appenderName) {
        final AppenderComponentBuilder appenderBuilder = builder.newAppender(appenderName,
                RollingFileAppender.PLUGIN_NAME);
        buildFileAppender(appenderName, appenderBuilder);
        final String fileName = getLog4jAppenderValue(appenderName, "File");
        final String datePattern = getLog4jAppenderValue(appenderName, "DatePattern", ".yyyy-MM-dd");
        appenderBuilder.addAttribute("filePattern", fileName + "%d{" + datePattern + "}");
        final ComponentBuilder<?> triggeringPolicy = builder.newComponent("Policies")
                .addComponent(builder.newComponent("TimeBasedTriggeringPolicy").addAttribute("modulate", true));
        appenderBuilder.addComponent(triggeringPolicy);
        appenderBuilder
                .addComponent(builder.newComponent("DefaultRolloverStrategy").addAttribute("max", Integer.MAX_VALUE));
        builder.add(appenderBuilder);
    }

    private void buildRollingFileAppender(final String appenderName) {
        final AppenderComponentBuilder appenderBuilder = builder.newAppender(appenderName,
                RollingFileAppender.PLUGIN_NAME);
        buildFileAppender(appenderName, appenderBuilder);
        final String fileName = getLog4jAppenderValue(appenderName, "File");
        appenderBuilder.addAttribute("filePattern", fileName + ".%i");
        final String maxFileSizeString = getLog4jAppenderValue(appenderName, "MaxFileSize", "10485760");
        final String maxBackupIndexString = getLog4jAppenderValue(appenderName, "MaxBackupIndex", "1");
        final ComponentBuilder<?> triggeringPolicy = builder.newComponent("Policies").addComponent(
                builder.newComponent("SizeBasedTriggeringPolicy").addAttribute("size", maxFileSizeString));
        appenderBuilder.addComponent(triggeringPolicy);
        appenderBuilder.addComponent(
                builder.newComponent("DefaultRolloverStrategy").addAttribute("max", maxBackupIndexString));
        builder.add(appenderBuilder);
    }

    private void buildAttribute(final String componentName, final ComponentBuilder<?> componentBuilder,
            final String sourceAttributeName, final String targetAttributeName) {
        final String attributeValue = getLog4jAppenderValue(componentName, sourceAttributeName);
        if (attributeValue != null) {
            componentBuilder.addAttribute(targetAttributeName, attributeValue);
        }
    }

    private void buildMandatoryAttribute(final String componentName, final ComponentBuilder<?> componentBuilder,
            final String sourceAttributeName, final String targetAttributeName) {
        final String attributeValue = getLog4jAppenderValue(componentName, sourceAttributeName);
        if (attributeValue != null) {
            componentBuilder.addAttribute(targetAttributeName, attributeValue);
        } else {
            reportWarning("Missing " + sourceAttributeName + " for " + componentName);
        }
    }

    private void buildNullAppender(final String appenderName) {
        final AppenderComponentBuilder appenderBuilder = builder.newAppender(appenderName, NullAppender.PLUGIN_NAME);
        builder.add(appenderBuilder);
    }

    private void buildAppenderLayout(final String name, final AppenderComponentBuilder appenderBuilder) {
        final String layoutClass = getLog4jAppenderValue(name, "layout", null);
        if (layoutClass != null) {
            switch (layoutClass) {
            case "org.apache.log4j.PatternLayout":
            case "org.apache.log4j.EnhancedPatternLayout": {
                String pattern = getLog4jAppenderValue(name, "layout.ConversionPattern", null);
                if (pattern != null) {
                    pattern = pattern
                            // Log4j 2 and Log4j 1 level names differ for custom levels
                            .replaceAll("%([-\\.\\d]*)p(?!\\w)", "%$1v1Level")
                            // Log4j 2's %x (NDC) is not compatible with Log4j 1's
                            // %x
                            // Log4j 1: "foo bar baz"
                            // Log4j 2: "[foo, bar, baz]"
                            // Use %ndc to get the Log4j 1 format
                            .replaceAll("%([-\\.\\d]*)x(?!\\w)", "%$1ndc")

                            // Log4j 2's %X (MDC) is not compatible with Log4j 1's
                            // %X
                            // Log4j 1: "{{foo,bar}{hoo,boo}}"
                            // Log4j 2: "{foo=bar,hoo=boo}"
                            // Use %properties to get the Log4j 1 format
                            .replaceAll("%([-\\.\\d]*)X(?!\\w)", "%$1properties");
                } else {
                    pattern = "%m%n";
                }
                appenderBuilder.add(newPatternLayout(pattern));
                break;
            }
            case "org.apache.log4j.SimpleLayout": {
                appenderBuilder.add(newPatternLayout("%v1Level - %m%n"));
                break;
            }
            case "org.apache.log4j.TTCCLayout": {
                String pattern = "";
                final String dateFormat = getLog4jAppenderValue(name, "layout.DateFormat", RELATIVE);
                final String timezone = getLog4jAppenderValue(name, "layout.TimeZone", null);
                if (dateFormat != null) {
                    if (RELATIVE.equalsIgnoreCase(dateFormat)) {
                        pattern += "%r ";
                    } else if (!NULL.equalsIgnoreCase(dateFormat)){
                        pattern += "%d{" + dateFormat + "}";
                        if (timezone != null) {
                            pattern += "{" + timezone + "}";
                        }
                        pattern += " ";
                    }
                }
                if (Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.ThreadPrinting", TRUE))) {
                    pattern += "[%t] ";
                }
                pattern += "%p ";
                if (Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.CategoryPrefixing", TRUE))) {
                    pattern += "%c ";
                }
                if (Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.ContextPrinting", TRUE))) {
                    pattern += "%notEmpty{%ndc }";
                }
                pattern += "- %m%n";
                appenderBuilder.add(newPatternLayout(pattern));
                break;
            }
            case "org.apache.log4j.HTMLLayout": {
                final LayoutComponentBuilder htmlLayout = builder.newLayout("HtmlLayout");
                htmlLayout.addAttribute("title", getLog4jAppenderValue(name, "layout.Title", "Log4J Log Messages"));
                htmlLayout.addAttribute("locationInfo",
                        Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.LocationInfo", FALSE)));
                appenderBuilder.add(htmlLayout);
                break;
            }
            case "org.apache.log4j.xml.XMLLayout": {
                final LayoutComponentBuilder xmlLayout = builder.newLayout("Log4j1XmlLayout");
                xmlLayout.addAttribute("locationInfo",
                        Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.LocationInfo", FALSE)));
                xmlLayout.addAttribute("properties",
                        Boolean.parseBoolean(getLog4jAppenderValue(name, "layout.Properties", FALSE)));
                appenderBuilder.add(xmlLayout);
                break;
            }
            default:
                reportWarning("Unknown layout class: " + layoutClass);
            }
        }
    }

    private LayoutComponentBuilder newPatternLayout(final String pattern) {
        final LayoutComponentBuilder layoutBuilder = builder.newLayout("PatternLayout");
        if (pattern != null) {
            layoutBuilder.addAttribute("pattern", pattern);
        }
        return layoutBuilder;
    }

    private void buildRootLogger(final String rootLoggerValue) {
        if (rootLoggerValue == null) {
            return;
        }
        final String[] rootLoggerParts = rootLoggerValue.split(COMMA_DELIMITED_RE);
        final String rootLoggerLevel = getLevelString(rootLoggerParts, Level.ERROR.name());
        final RootLoggerComponentBuilder loggerBuilder = builder.newRootLogger(rootLoggerLevel);
        //
        final String[] sortedAppenderNames = Arrays.copyOfRange(rootLoggerParts, 1, rootLoggerParts.length);
        Arrays.sort(sortedAppenderNames);
        for (final String appender : sortedAppenderNames) {
            loggerBuilder.add(builder.newAppenderRef(appender));
        }
        builder.add(loggerBuilder);
    }

    private String getLevelString(final String[] loggerParts, final String defaultLevel) {
        return loggerParts.length > 0 ? loggerParts[0] : defaultLevel;
    }

    private void buildLoggers(final String prefix) {
        final int preLength = prefix.length();
        for (final Map.Entry<Object, Object> entry : properties.entrySet()) {
            final Object keyObj = entry.getKey();
            if (keyObj != null) {
                final String key = keyObj.toString().trim();
                if (key.startsWith(prefix)) {
                    final String name = key.substring(preLength);
                    final Object value = entry.getValue();
                    if (value != null) {
                        // a Level may be followed by a list of Appender refs.
                        final String valueStr = value.toString().trim();
                        final String[] split = valueStr.split(COMMA_DELIMITED_RE);
                        final String level = getLevelString(split, null);
                        if (level == null) {
                            warn("Level is missing for entry " + entry);
                        } else {
                            final LoggerComponentBuilder newLogger = builder.newLogger(name, level);
                            if (split.length > 1) {
                                // Add Appenders to this logger
                                final String[] sortedAppenderNames = Arrays.copyOfRange(split, 1, split.length);
                                Arrays.sort(sortedAppenderNames);
                                for (final String appenderName : sortedAppenderNames) {
                                    newLogger.add(builder.newAppenderRef(appenderName));
                                }
                            }
                            builder.add(newLogger);
                        }
                    }
                }
            }
        }
    }

    private String getLog4jAppenderValue(final String appenderName, final String attributeName) {
        return getProperty("log4j.appender." + appenderName + "." + attributeName);
    }

    private String getProperty(final String key) {
        final String value = properties.getProperty(key);
        final String substVars = OptionConverter.substVars(value, properties);
        return substVars == null ? null : substVars.trim();
    }

    private String getProperty(final String key, final String defaultValue) {
        final String value = getProperty(key);
        return value == null ? defaultValue : value;
    }

    private String getLog4jAppenderValue(final String appenderName, final String attributeName,
            final String defaultValue) {
        return getProperty("log4j.appender." + appenderName + "." + attributeName, defaultValue);
    }

    private String getLog4jValue(final String key) {
        return getProperty("log4j." + key);
    }

    private void reportWarning(final String msg) {
        StatusLogger.getLogger().warn("Log4j 1 configuration parser: " + msg);
    }

}
