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

import jakarta.servlet.ServletContext;
import java.net.URI;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.AbstractLifeCycle;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.async.AsyncLoggerContext;
import org.apache.logging.log4j.core.config.Configurator;
import org.apache.logging.log4j.core.impl.ContextAnchor;
import org.apache.logging.log4j.core.impl.Log4jContextFactory;
import org.apache.logging.log4j.core.lookup.ConfigurationStrSubstitutor;
import org.apache.logging.log4j.core.lookup.Interpolator;
import org.apache.logging.log4j.core.lookup.StrSubstitutor;
import org.apache.logging.log4j.core.selector.ContextSelector;
import org.apache.logging.log4j.core.selector.NamedContextSelector;
import org.apache.logging.log4j.core.util.Loader;
import org.apache.logging.log4j.core.util.NetUtils;
import org.apache.logging.log4j.spi.LoggerContextFactory;
import org.apache.logging.log4j.util.LoaderUtil;
import org.apache.logging.log4j.util.Strings;

/**
 * This class initializes and deinitializes Log4j no matter how the initialization occurs.
 */
final class Log4jWebInitializerImpl extends AbstractLifeCycle implements Log4jWebLifeCycle {

    private static final String WEB_INF = "/WEB-INF/";

    static {
        if (Loader.isClassAvailable("org.apache.logging.log4j.core.web.JNDIContextFilter")) {
            throw new IllegalStateException("You are using Log4j 2 in a web application with the old, extinct "
                    + "log4j-web artifact. This is not supported and could cause serious runtime problems. Please"
                    + "remove the log4j-web JAR file from your application.");
        }
    }

    private final Map<String, String> map = new ConcurrentHashMap<>();
    private final StrSubstitutor substitutor = new ConfigurationStrSubstitutor(new Interpolator(map));
    private final ServletContext servletContext;

    private String name;
    private NamedContextSelector namedContextSelector;
    private LoggerContext loggerContext;

    private Log4jWebInitializerImpl(final ServletContext servletContext) {
        this.servletContext = servletContext;
        this.map.put("hostName", NetUtils.getLocalHostname());
    }

    /**
     * Initializes the Log4jWebLifeCycle attribute of a ServletContext. Those who wish to obtain this object should use
     * the {@link org.apache.logging.log4j.web.WebLoggerContextUtils#getWebLifeCycle(jakarta.servlet.ServletContext)}
     * method instead.
     *
     * @param servletContext
     *        the ServletContext to initialize
     * @return a new Log4jWebLifeCycle
     * @since 2.0.1
     */
    protected static Log4jWebInitializerImpl initialize(final ServletContext servletContext) {
        final Log4jWebInitializerImpl initializer = new Log4jWebInitializerImpl(servletContext);
        servletContext.setAttribute(SUPPORT_ATTRIBUTE, initializer);
        return initializer;
    }

    @Override
    public synchronized void start() {
        if (this.isStopped() || this.isStopping()) {
            throw new IllegalStateException("Cannot start this Log4jWebInitializerImpl after it was stopped.");
        }

        // only do this once
        if (this.isInitialized()) {
            super.setStarting();

            this.name = this.substitutor.replace(this.servletContext.getInitParameter(LOG4J_CONTEXT_NAME));
            final String location =
                    this.substitutor.replace(this.servletContext.getInitParameter(LOG4J_CONFIG_LOCATION));
            final boolean isJndi =
                    "true".equalsIgnoreCase(this.servletContext.getInitParameter(IS_LOG4J_CONTEXT_SELECTOR_NAMED));

            if (isJndi) {
                this.initializeJndi(location);
            } else {
                this.initializeNonJndi(location);
            }
            if (this.loggerContext instanceof AsyncLoggerContext) {
                ((AsyncLoggerContext) this.loggerContext).setUseThreadLocals(false);
            }

            this.servletContext.setAttribute(CONTEXT_ATTRIBUTE, this.loggerContext);
            super.setStarted();
        }
    }

    private void initializeJndi(final String location) {
        final URI configLocation = getConfigURI(location);

        if (this.name == null) {
            throw new IllegalStateException("A log4jContextName context parameter is required");
        }

        LoggerContext context;
        final LoggerContextFactory factory = LogManager.getFactory();
        if (factory instanceof Log4jContextFactory) {
            final ContextSelector selector = ((Log4jContextFactory) factory).getSelector();
            if (selector instanceof NamedContextSelector) {
                this.namedContextSelector = (NamedContextSelector) selector;
                context = this.namedContextSelector.locateContext(
                        this.name, WebLoggerContextUtils.createExternalEntry(this.servletContext), configLocation);
                ContextAnchor.THREAD_CONTEXT.set(context);
                if (context.isInitialized()) {
                    context.start();
                }
                ContextAnchor.THREAD_CONTEXT.remove();
            } else {
                LOGGER.warn("Potential problem: Selector is not an instance of NamedContextSelector.");
                return;
            }
        } else {
            LOGGER.warn("Potential problem: LoggerContextFactory is not an instance of Log4jContextFactory.");
            return;
        }
        this.loggerContext = context;
        LOGGER.debug(
                "Created logger context for [{}] using [{}].",
                this.name,
                context.getClass().getClassLoader());
    }

    private void initializeNonJndi(final String location) {
        if (this.name == null) {
            this.name = this.servletContext.getServletContextName();
            LOGGER.debug("Using the servlet context name \"{}\".", this.name);
        }
        if (this.name == null) {
            this.name = this.servletContext.getContextPath();
            LOGGER.debug("Using the servlet context context-path \"{}\".", this.name);
        }
        if (this.name == null && location == null) {
            LOGGER.error("No Log4j context configuration provided. This is very unusual.");
            this.name = new SimpleDateFormat("yyyyMMdd_HHmmss.SSS").format(new Date());
        }
        if (location != null && location.contains(",")) {
            final List<URI> uris = getConfigURIs(location);
            this.loggerContext = Configurator.initialize(
                    this.name,
                    this.getClassLoader(),
                    uris,
                    WebLoggerContextUtils.createExternalEntry(this.servletContext));
            return;
        }

        final URI uri = getConfigURI(location);
        this.loggerContext = Configurator.initialize(
                this.name, this.getClassLoader(), uri, WebLoggerContextUtils.createExternalEntry(this.servletContext));
    }

    private List<URI> getConfigURIs(final String location) {
        final String[] parts = location.split(",");
        final List<URI> uris = new ArrayList<>(parts.length);
        for (final String part : parts) {
            final URI uri = getConfigURI(part);
            if (uri != null) {
                uris.add(uri);
            }
        }
        return uris;
    }

    private URI getConfigURI(final String location) {
        try {
            String configLocation = location;
            if (configLocation == null) {
                final String[] paths = prefixSet(servletContext.getResourcePaths(WEB_INF), WEB_INF + "log4j2");
                LOGGER.debug(
                        "getConfigURI found resource paths {} in servletContext at [{}]",
                        Arrays.toString(paths),
                        WEB_INF);
                if (paths.length == 1) {
                    configLocation = paths[0];
                } else if (paths.length > 1) {
                    final String prefix = WEB_INF + "log4j2-" + this.name + ".";
                    boolean found = false;
                    for (final String str : paths) {
                        if (str.startsWith(prefix)) {
                            configLocation = str;
                            found = true;
                            break;
                        }
                    }
                    if (!found) {
                        configLocation = paths[0];
                    }
                }
            }
            if (configLocation != null) {
                final URL url = servletContext.getResource(configLocation);
                if (url != null) {
                    final URI uri = url.toURI();
                    LOGGER.debug("getConfigURI found resource [{}] in servletContext at [{}]", uri, configLocation);
                    return uri;
                }
            }
        } catch (final Exception ex) {
            // Just try passing the location.
        }
        if (location != null) {
            try {
                final URI correctedFilePathUri = NetUtils.toURI(location);
                LOGGER.debug("getConfigURI found [{}] in servletContext at [{}]", correctedFilePathUri, location);
                return correctedFilePathUri;
            } catch (final Exception e) {
                LOGGER.error("Unable to convert configuration location [{}] to a URI", location, e);
            }
        }
        return null;
    }

    /**
     * Collects strings starting with the given {@code prefix} from the given {@code set}.
     *
     * @param set a (nullable) set of strings
     * @param prefix a prefix to look for in the string set
     * @return an array of the matching strings from the given set
     */
    @SuppressWarnings("SameParameterValue")
    private static String[] prefixSet(final Set<String> set, final String prefix) {
        if (set == null) {
            return Strings.EMPTY_ARRAY;
        }
        return set.stream().filter(string -> string.startsWith(prefix)).toArray(String[]::new);
    }

    @Override
    public synchronized boolean stop(final long timeout, final TimeUnit timeUnit) {
        if (!this.isStarted() && !this.isStopped()) {
            throw new IllegalStateException("Cannot stop this Log4jWebInitializer because it has not started.");
        }

        // only do this once
        if (this.isStarted()) {
            this.setStopping();
            if (this.loggerContext != null) {
                LOGGER.debug("Removing LoggerContext for [{}].", this.name);
                this.servletContext.removeAttribute(CONTEXT_ATTRIBUTE);
                if (this.namedContextSelector != null) {
                    this.namedContextSelector.removeContext(this.name);
                }
                this.loggerContext.stop(timeout, timeUnit);
                this.loggerContext.setExternalContext(null);
                this.loggerContext = null;
            }
            this.setStopped();
        }
        return super.stop(timeout, timeUnit);
    }

    @Override
    public void setLoggerContext() {
        if (this.loggerContext != null) {
            ContextAnchor.THREAD_CONTEXT.set(this.loggerContext);
        }
    }

    @Override
    public void clearLoggerContext() {
        ContextAnchor.THREAD_CONTEXT.remove();
    }

    @Override
    public void wrapExecution(final Runnable runnable) {
        this.setLoggerContext();

        try {
            runnable.run();
        } finally {
            this.clearLoggerContext();
        }
    }

    private ClassLoader getClassLoader() {
        try {
            // if container is Servlet 3.0, use its getClassLoader method
            // this may look odd, but the call below will throw NoSuchMethodError if user is on Servlet 2.5
            // we compile against 3.0 to support Log4jServletContainerInitializer, but we don't require 3.0
            return this.servletContext.getClassLoader();
        } catch (final Throwable ignore) {
            // LOG4J2-248: use TCCL if possible
            return LoaderUtil.getThreadContextClassLoader();
        }
    }
}
