package com.atlassian.stash.internal.log;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import com.atlassian.cache.*;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.bitbucket.log.LoggingService;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.annotation.NotProfiled;
import com.atlassian.stash.internal.concurrent.StatefulService;
import com.atlassian.stash.internal.concurrent.TransferableState;
import com.atlassian.stash.internal.config.log.InternalLoggingService;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.hazelcast.internal.monitors.HealthMonitor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import javax.annotation.Nonnull;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.*;

/**
 * <a href="http://logback.qos.ch/apidocs/index.html">Logback</a>-specific implementation of the {@link LoggingService}.
 * <p>
 * This service is {@link StatefulService stateful}, so when tasks are submitted to executors the Logback MDC is
 * transferred to the executor thread which processes the task.
 * <p>
 * As a side benefit, this service also searches for configuration properties with {@link #PROPERTY_PREFIXES select
 * prefixes} and uses them to set the levels of specified loggers.
 */
@AvailableToPlugins(LoggingService.class)
@Service("loggingService")
public class LogbackLoggingService implements InternalLoggingService, StatefulService {

    private static final Map<String, Level> DEBUG_LOG_LEVELS = ImmutableMap.<String, Level>builder()
            .put(Logger.ROOT_LOGGER_NAME, Level.INFO)
            .put("com.atlassian.bitbucket", Level.DEBUG)
            .put("com.atlassian.event.remote", Level.DEBUG)
            .put("com.atlassian.stash", Level.DEBUG)
            .put("org.hibernate", Level.INFO)
            .put("org.springframework", Level.INFO)
            .put(HealthMonitor.class.getName(), Level.DEBUG)
            .build();
    private static final String MAP_LOGGING_STATE = "logging.state";

    public static final String PREFIX_PROPERTY_GENERIC = "logging.logger.";
    public static final String PREFIX_PROPERTY_LOG4J = "log4j.logger.";
    public static final String PREFIX_ORIGINAL_LOGGER = "__";
    public static final Set<String> PROPERTY_PREFIXES = ImmutableSet.of(PREFIX_PROPERTY_LOG4J, PREFIX_PROPERTY_GENERIC);

    private final LoggingStateListener listener;
    private final Cache<String, String> loggingState;   // for cluster awareness, tracks state of the loggers
    private final ApplicationPropertiesService propertiesService;
    private final I18nService i18nService;
    private final Properties properties;

    @Autowired
    public LogbackLoggingService(CacheFactory cacheFactory, I18nService i18nService,
                                 @Qualifier("applicationProperties") Properties properties,
                                 ApplicationPropertiesService applicationPropertiesService) {
        this.i18nService = i18nService;
        this.properties = properties;
        this.propertiesService = applicationPropertiesService;

        listener = new LoggingStateListener();
        loggingState = cacheFactory.getCache(MAP_LOGGING_STATE, null,
                new CacheSettingsBuilder().remote().maxEntries(Integer.MAX_VALUE).build());
        loggingState.addListener(listener, true);
    }

    /**
     * Applies levels defined as configuration properties, allowing the levels to be modified without requiring any
     * changes to the {@code logback.xml} file.
     */
    @PostConstruct
    public void applyLevelProperties() {
        Collection<String> keys = loggingState.getKeys();
        if (keys.isEmpty()) {
            // no log levels set at all. This means that this is the first node to start up.
            Enumeration<?> propertyNames = properties.propertyNames();
            while (propertyNames.hasMoreElements()) {
                String propertyName = (String) propertyNames.nextElement();

                //We only care about log4j.logger.* OR logging.logger.* properties:
                for (String prefix : PROPERTY_PREFIXES) {
                    if (propertyName.startsWith(prefix)) {
                        String levelName = StringUtils.trimToNull(properties.getProperty(propertyName));
                        if (levelName != null) {
                            String loggerName = propertyName.substring(prefix.length());
                            setLevel(loggerName, levelName);
                        }
                    }
                }
            }

            // if debug logging has been enabled at the server level (though the admin ui), this should override any settings
            // in either logback.xml or the properties file.
            if (propertiesService.isDebugLoggingEnabled()) {
                enableDebugLogging();
            }
        } else {
            // loggingState map already contains logger state, apply it locally
            for (String key : keys) {
                doSetLevel(key, loggingState.get(key));
            }
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public void disableDebugLogging() {
        for (String loggerName : DEBUG_LOG_LEVELS.keySet()) {
            restoreStandardLogLevel(loggerName);
        }
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public void enableDebugLogging() {
        for (Map.Entry<String, Level> entry : DEBUG_LOG_LEVELS.entrySet()) {
            // store the current level before enabling debug mode, so we can restore them on restoreStandardLogLevel
            String loggerName = entry.getKey();
            Level currentLevel = getLogbackLevel(loggerName);
            Level desiredLevel = entry.getValue();
            if (!desiredLevel.isGreaterOrEqual(currentLevel)) {
                // only change the level if the currentLevel > desiredLevel
                setLevel(PREFIX_ORIGINAL_LOGGER + loggerName, currentLevel.levelStr);
                setLevel(loggerName, desiredLevel.levelStr);
            }
        }
    }
    
    @Override
    @Nonnull
    @NotProfiled
    public TransferableState getState() {
        return new MdcState(MDC.getCopyOfContextMap());
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public String getLevel(String loggerName) {
        Logger logger = getLogger(loggerName);
        Level effectiveLevel = logger.getEffectiveLevel();

        return effectiveLevel.levelStr;
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public String getRootLevel() {
        return getLevel(Logger.ROOT_LOGGER_NAME);
    }

    /**
     * Set the logging level for an individual named logger.
     * Note that in a Stash cluster this is propagated to all cluster nodes.
     *
     * @param loggerName the name of the logger to adjust the output level for
     * @param levelName the logging level to apply
     */
    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public void setLevel(String loggerName, String levelName) {
        String name = StringUtils.trimToNull(loggerName);
        if (name == null) {
            String message = i18nService.getMessage("bitbucket.logging.logger.name.required");
            throw new IllegalArgumentException(message);
        }

        loggingState.put(name, levelName); // the listener calls doSetLevel (on each node)
    }

    /**
     * Set the logging level for the ROOT logger.
     * Note that in a Stash cluster this is propagated to all cluster nodes.
     *
     * @param levelName the logging level
     */
    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public void setRootLevel(String levelName) {
        setLevel(Logger.ROOT_LOGGER_NAME, levelName);
    }

    public void restoreStandardLogLevel(String loggerName) {
        String originalLevel = loggingState.get(PREFIX_ORIGINAL_LOGGER + loggerName);
        if (originalLevel != null) {
            loggingState.remove(PREFIX_ORIGINAL_LOGGER + loggerName);
            setLevel(loggerName, originalLevel);
        }
    }

    private void doSetLevel(String name, String levelName) {
        if (name.startsWith(PREFIX_ORIGINAL_LOGGER)) {
            // just storing the original logger so it can be restored later on. No need to actually set a log level
            return;
        }
        Level level = toLevel(levelName);

        Logger logger = getLogger(name);
        Level effectiveLevel = logger.getEffectiveLevel();
        if (!effectiveLevel.equals(level)) { //Only set the level if it's different
            EnhancedLevel.fromLevel(effectiveLevel).log(logger, "Switching to log level [{}]", levelName);

            logger.setLevel(level);
        }

    }
    private Logger getLogger(String name) {
        LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
        return lc.getLogger(name);
    }

    private Level getLogbackLevel(String loggerName) {
        return getLogger(loggerName).getEffectiveLevel();
    }

    @PreDestroy
    private void onShutdown() {
        loggingState.removeListener(listener);
    }

    private Level toLevel(String levelName) {
        Level level = Level.toLevel(levelName, null);
        if (level == null) {
            String message = i18nService.getMessage("bitbucket.logging.level.invalid", levelName);
            throw new IllegalArgumentException(message);
        }
        return level;
    }

    private static final class MdcState implements TransferableState {

        private final Map<String, String> state;

        private MdcState(Map<String, String> state) {
            this.state = state;
        }

        @Override
        public void apply() {
            if (state != null) {
                MDC.setContextMap(state);
            }
        }

        @Override
        public void remove() {
            MDC.clear();
        }
    }

    private class LoggingStateListener extends CacheEntryAdapter<String, String> {
        @Override
        public void onAdd(@Nonnull CacheEntryEvent<String, String> event) {
            doSetLevel(event.getKey(), event.getValue());
        }

        @Override
        public void onEvict(@Nonnull CacheEntryEvent<String, String> event) {
            restoreStandardLogLevel(event.getKey());
        }

        @Override
        public void onRemove(@Nonnull CacheEntryEvent<String, String> event) {
            restoreStandardLogLevel(event.getKey());
        }

        @Override
        public void onUpdate(@Nonnull CacheEntryEvent<String, String> event) {
            doSetLevel(event.getKey(), event.getValue());

        }
    }
}
