package com.atlassian.bitbucket.mesh.junit;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import org.hamcrest.Matcher;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;

import static com.atlassian.bitbucket.mesh.test.EventualMatcher.eventually;
import static java.util.Objects.requireNonNull;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasItem;

/**
 * JUnit {@link org.junit.Rule} that can be used to verify INFO, WARN and ERROR log messages.
 * <p>
 * In addition, {@code LoggerListener} suppresses log messages to keep the logs clean (but this can be disabled)
 */
public class LogListener implements TestRule {

    private final List<Logger> loggersUnderTest;
    private final boolean quiet;
    private final RecordingAppender recordingAppender;

    /**
     * Creates a {@link LogListener} that intercepts and suppresses log lines written to the logger for {@code clazz}.
     *
     * @param clazz the class used as the logger name in {@link LoggerFactory#getLogger(Class)}
     */
    public LogListener(@Nonnull Class<?> clazz) {
        this(LoggerFactory.getLogger(requireNonNull(clazz, "clazz")), true);
    }

    /**
     * Creates a {@link LogListener} that intercepts log lines written to the logger for {@code clazz}. If {@code quiet}
     * is set to {@code true}. Any log lines logged to the logger are suppressed.
     *
     * @param clazz the class used as the logger name in {@link LoggerFactory#getLogger(Class)}
     * @param quiet if {@code true} any log lines logged to the logger are suppressed
     */
    public LogListener(@Nonnull Class<?> clazz, boolean quiet) {
        this(LoggerFactory.getLogger(requireNonNull(clazz, "clazz")), quiet);
    }

    /**
     * Creates a {@link LogListener} that intercepts and suppresses log lines written to the logger for
     * {@code loggerName}.
     *
     * @param loggerName the logger name used in {@link LoggerFactory#getLogger(String)}
     */
    public LogListener(@Nonnull String loggerName) {
        this(LoggerFactory.getLogger(requireNonNull(loggerName, "loggerName")), true);
    }

    /**
     * Creates a {@link LogListener} that intercepts log lines written to the logger for {@code loggerName}.
     * If {@code quiet} is set to {@code true}. Any log lines logged to the logger are suppressed.
     *
     * @param loggerName the logger name used in {@link LoggerFactory#getLogger(String)}
     * @param quiet if {@code true} any log lines logged to the logger are suppressed
     */
    public LogListener(@Nonnull String loggerName, boolean quiet) {
        this(LoggerFactory.getLogger(requireNonNull(loggerName, "loggerName")), quiet);
    }

    /**
     * Creates a {@link LogListener} that intercepts log lines written to {@code loggerUnderTest}. If {@code quiet}
     * is set to {@code true}. Any log lines logged to the logger are suppressed.
     *
     * @param loggerUnderTest the logger to intercept
     * @param quiet if {@code true} any log lines logged to the logger are suppressed
     */
    public LogListener(@Nonnull org.slf4j.Logger loggerUnderTest, boolean quiet) {
        requireNonNull(loggerUnderTest, "loggerUnderTest");
        if (!(loggerUnderTest instanceof Logger)) {
            throw new IllegalArgumentException("Only logback Logger is supported");
        }
        this.quiet = quiet;

        loggersUnderTest = new ArrayList<>();
        loggersUnderTest.add((Logger) loggerUnderTest);
        recordingAppender = new RecordingAppender();
    }

    @Nonnull
    public LogListener intercept(@Nonnull String loggerName) {
        return intercept(LoggerFactory.getLogger(requireNonNull(loggerName, "loggerName")));
    }

    @Nonnull
    public LogListener intercept(@Nonnull Class<?> clazz) {
        return intercept(LoggerFactory.getLogger(requireNonNull(clazz, "clazz")));
    }

    @Nonnull
    public LogListener intercept(@Nonnull org.slf4j.Logger loggerUnderTest) {
        loggersUnderTest.add((Logger) requireNonNull(loggerUnderTest, "loggerUnderTest"));
        return this;
    }

    @Override
    public Statement apply(Statement base, Description description) {
        return new Statement() {

            @Override
            public void evaluate() throws Throwable {
                recordingAppender.reset();

                List<LoggerSnapshot> previousSettings = loggersUnderTest.stream()
                        .map(LoggerSnapshot::new)
                        .collect(Collectors.toList());
                for (Logger logger : loggersUnderTest) {
                    //Add our capturing appender
                    logger.addAppender(recordingAppender);
                    if (quiet) {
                        logger.setAdditive(false);
                    }
                    logger.setLevel(Level.DEBUG);
                }

                try {
                    base.evaluate();
                } finally {
                    //Restore the appenders and detach our recordingAppender
                    previousSettings.forEach(LoggerSnapshot::restore);
                }
            }
        };
    }

    public void assertDebug(@Nonnull Matcher<String> messageMatcher) {
        recordingAppender.assertMessagesLogged(Level.DEBUG, requireNonNull(messageMatcher, "messageMatcher"));
    }

    public void assertDebug(@Nonnull String message) {
        recordingAppender.assertMessagesLogged(Level.DEBUG, containsString(requireNonNull(message, "message")));
    }

    public void assertError(@Nonnull Matcher<String> messageMatcher) {
        recordingAppender.assertMessagesLogged(Level.ERROR, requireNonNull(messageMatcher, "messageMatcher"));
    }

    public void assertError(@Nonnull String message) {
        recordingAppender.assertMessagesLogged(Level.ERROR, containsString(requireNonNull(message, "message")));
    }

    public void assertInfo(@Nonnull Matcher<String> messageMatcher) {
        recordingAppender.assertMessagesLogged(Level.INFO, requireNonNull(messageMatcher, "messageMatcher"));
    }

    public void assertInfo(@Nonnull String message) {
        recordingAppender.assertMessagesLogged(Level.INFO, containsString(requireNonNull(message, "message")));
    }

    public void assertNothingLogged() {

    }

    public void assertWarn(@Nonnull Matcher<String> messageMatcher) {
        recordingAppender.assertMessagesLogged(Level.WARN, requireNonNull(messageMatcher, "messageMatcher"));
    }

    public void assertWarn(@Nonnull String message) {
        recordingAppender.assertMessagesLogged(Level.WARN, containsString(requireNonNull(message, "message")));
    }

    public void reset() {
        recordingAppender.reset();
    }

    private class LoggerSnapshot {

        private final boolean additive;
        private final Level level;
        private final Logger logger;

        LoggerSnapshot(Logger logger) {
            this.logger = logger;
            additive = logger.isAdditive();
            level = logger.getLevel();
        }

        void restore() {
            logger.setAdditive(additive);
            logger.setLevel(level);
            logger.detachAppender(recordingAppender);
        }
    }

    private static class RecordingAppender extends AppenderBase<ILoggingEvent> {

        private final List<ILoggingEvent> events = new CopyOnWriteArrayList<>();

        RecordingAppender() {
            start();
        }

        @Override
        protected void append(ILoggingEvent eventObject) {
            events.add(eventObject);
        }

        void assertMessagesLogged(Level logLevel, Matcher<String> messageMatcher) {
            List<String> messages = events.stream()
                    .filter(event -> event.getLevel().equals(logLevel))
                    .map(ILoggingEvent::getFormattedMessage)
                    .collect(Collectors.toList());
            //Logging can sometimes happen asynchronously from another thread. To prevent test
            //flakiness, we'll retry up to 50ms until the assertion passes.
            assertThat(messages, eventually(hasItem(messageMatcher), Duration.ofMillis(50L)));
        }

        void assertNothingLogged() {
            assertThat(events, empty());
        }

        void reset() {
            events.clear();
        }
    }
}
