package com.atlassian.stash.internal.mail;

import com.atlassian.bitbucket.mail.*;
import com.atlassian.cache.CacheFactory;
import com.atlassian.cache.CacheSettingsBuilder;
import com.atlassian.cache.CachedReference;
import com.atlassian.cache.Supplier;
import com.atlassian.event.api.EventListener;
import com.atlassian.fugue.Maybe;
import com.atlassian.bitbucket.event.server.MailHostConfigurationChangedEvent;
import com.atlassian.bitbucket.mail.MailSizeExceededException;
import com.atlassian.bitbucket.mail.NoMailHostConfigurationException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.concurrent.PauseableExecutorService;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Ints;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.Conventions;
import org.springframework.mail.MailAuthenticationException;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMailMessage;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.mail.javamail.MimeMessagePreparator;
import org.springframework.security.access.prepost.PreAuthorize;

import javax.mail.AuthenticationFailedException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.List;
import java.util.Map;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;

import static com.atlassian.fugue.Option.option;
import static com.atlassian.stash.internal.mail.MailQueueSizeGuard.Claim;
import static com.atlassian.stash.internal.mail.MailUtils.computeSize;
import static com.atlassian.bitbucket.util.TextUtils.pluralise;

/**
 * An implementation of the {@link InternalMailService} interface which uses a {@link JavaMailSenderFactory} to create
 * instances of Spring's {@code JavaMailSender} for sending messages.
 */
public class MailServiceImpl implements InternalMailService {
    private static final String MAIL_SEND_TIMER = Conventions.getQualifiedAttributeName(MailServiceImpl.class, "sendMessageSynchronously");

    private final ApplicationPropertiesService applicationPropertiesService;
    private final I18nService i18nService;
    private final JavaMailSenderFactory senderFactory;
    private final MailLogger mailLogger;
    private final PauseableExecutorService pauseableExecutorService;
    private final long sendFailurePauseMillis;
    private final int maxMailSize;
    private final CachedReference<Maybe<JavaMailSender>> javaMailSender;

    private int connectTimeout;
    private int sendTimeout;
    private int testConnectTimeout;
    private int testSendTimeout;
    private MailQueueSizeGuard guard;

    public MailServiceImpl(ApplicationPropertiesService propertiesService,
                           I18nService i18nService,
                           JavaMailSenderFactory senderFactory,
                           PauseableExecutorService pauseableExecutorService,
                           MailQueueSizeGuard mailQueueGuard,
                           final MailLogger mailLogger,
                           CacheFactory cacheFactory,
                           int sendFailurePauseSeconds,
                           int maxMailSize) {
        Preconditions.checkArgument(sendFailurePauseSeconds >= 0, "mail.error.pause.retry is less than 0");

        this.mailLogger = mailLogger;
        this.pauseableExecutorService = pauseableExecutorService;
        this.applicationPropertiesService = propertiesService;
        this.i18nService = i18nService;
        this.senderFactory = senderFactory;
        this.sendFailurePauseMillis = TimeUnit.SECONDS.toMillis(sendFailurePauseSeconds);
        this.maxMailSize = maxMailSize;
        this.guard = mailQueueGuard;
        this.javaMailSender = cacheFactory.getCachedReference(MailService.class, "StashMailConfiguration", new Supplier<Maybe<JavaMailSender>>() {
            @Override
            public Maybe<JavaMailSender> get() {
                return option(buildMailSender(applicationPropertiesService.getMailHostConfiguration()));
            }
        }, new CacheSettingsBuilder().replicateViaInvalidation().build());
    }

    public void initMailSender() {
        mailLogger.logInfoMessage("Starting mail service");
        javaMailSender.get();
    }

    public void shutdown() {
        // Make sure we log this before our mail queue rejected messages get into the mail log
        mailLogger.logInfoMessage("Shutting down mail service");
        // This will dump all queued messages to the rejected message handler
        List<Runnable> unprocessedMessages = pauseableExecutorService.shutdownNow();
        int unprocessedCount = unprocessedMessages.size();
        if (unprocessedCount > 0) {
            String message = String.format("Discarding %d unsent mail %s", unprocessedCount, pluralise("message", unprocessedCount));
            mailLogger.logInfoMessage(message);
        }
    }

    @Override
    @Unsecured("this needs to be available in all contexts")
    public boolean isHostConfigured() {
        return javaMailSender.get().isDefined();
    }

    @EventListener
    public void onConfigChanged(MailHostConfigurationChangedEvent event) {
        javaMailSender.reset();
        mailLogger.logInfoMessage("MailService reconfigured");
    }

    @Override
    @Unsecured("currently we are not restricting messaging by permission")
    public void submit(final MailMessage mailMessage) throws NoMailHostConfigurationException, MailQueueFullException {
        if (isHostConfigured()) {
            // This is check inside sendNow, but we want to check it before we put it on the queue
            checkMailSize(mailMessage);
            try {
                final Claim reservation = guard.claimSpace(mailMessage);
                pauseableExecutorService.submit(new Runnable() {
                    @Override
                    public void run() {
                        // If there is an error talking to the mail server, pause mail sending
                        try {
                            sendNow(mailMessage);
                        } catch (com.atlassian.bitbucket.mail.MailAuthenticationException | MailSendException ex) {
                            pauseExecutor();
                        } finally {
                            reservation.release();
                        }
                    }
                });
            } catch (InsufficientSpaceOnMailQueueException | RejectedExecutionException ex) {
                handleQueueFull(mailMessage, ex);
            }
        } else {
            throw throwMailHostConfiguredException(mailMessage);
        }
    }

    private void handleQueueFull(MailMessage mailMessage, Exception ex) {
        KeyedMessage errorMessage = i18nService.createKeyedMessage("bitbucket.service.mail.queuefull",
                mailMessage.getSubject());
        mailLogger.logError(errorMessage.getLocalisedMessage(), mailMessage);
        throw new MailQueueFullException(errorMessage, ex);
    }

    private void pauseExecutor() {
        pauseableExecutorService.pauseFor(sendFailurePauseMillis, TimeUnit.MILLISECONDS);
    }

    @Override
    @Unsecured("currently we are not restricting messaging by permission")
    public void sendNow(MailMessage message) throws MailException {
        Maybe<JavaMailSender> javaMailSender = this.javaMailSender.get();
        if (javaMailSender.isEmpty()) {
            throw throwMailHostConfiguredException(message);
        }
        sendMessageSynchronously(javaMailSender.get(), message);
    }

    private NoMailHostConfigurationException throwMailHostConfiguredException(MailMessage mailMessage) throws NoMailHostConfigurationException {
        KeyedMessage errorMessage = i18nService.createKeyedMessage("bitbucket.service.noemailconfig");
        NoMailHostConfigurationException e = new NoMailHostConfigurationException(errorMessage);
        mailLogger.logError(e.getLocalizedMessage(), mailMessage, e);
        throw e;
    }

    /**
     * Check if the mail is too large, and if so, log the exception to the mail log
     * @param message the message
     * @throws MailSizeExceededException if provided message is larger than the configured limit               
     */
    private void checkMailSize(MailMessage message) throws MailSizeExceededException {
        int size = computeSize(message);
        if (size > maxMailSize) {
            MailSizeExceededException e = new MailSizeExceededException(i18nService.createKeyedMessage("bitbucket.service.mailsizeexceeded",
                    size, maxMailSize));
            mailLogger.logError(e.getLocalizedMessage(), message, e);
            throw e;
        }
    }

    //Only administrators should be able to send test messages using a provided configuration rather than the set one.
    @Override
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN')")
    public void sendTest(MailHostConfiguration configuration, MailMessage message) throws MailException {
        sendMessageSynchronously(buildMailSender(configuration, testConnectTimeout, testSendTimeout), message);
    }

    private void sendMessageSynchronously(JavaMailSender javaMailSender, MailMessage message) throws MailException {
        try (Timer ignored = TimerUtils.start(MAIL_SEND_TIMER)) {
            checkMailSize(message);
            javaMailSender.send(newMimeMailMessagePreparator(message));
        } catch (MailAuthenticationException e) {
            KeyedMessage errorMessage = translateAuthenticationFailure(e);
            mailLogger.logError(errorMessage.getLocalisedMessage(), message, e);
            throw new com.atlassian.bitbucket.mail.MailAuthenticationException(errorMessage, e);
        } catch (MailSizeExceededException e) {
            // Already logged
            throw e;
        } catch (Exception e) {
            KeyedMessage errorMessage = getSendErrorMessage();
            mailLogger.logError(errorMessage.getLocalisedMessage(), message, e);
            throw new MailSendException(errorMessage, e);
        } finally {
            // In case this ever has an error, make sure we pop the timer first!
            mailLogger.flush();
        }
    }

    private KeyedMessage translateAuthenticationFailure(MailAuthenticationException e) {
        if (e.getCause() instanceof AuthenticationFailedException) {
            String message = e.getCause().getMessage();
            // Note, the first condition is _not_ a mistake.
            // There is a misspelling javax.mail
            if (StringUtils.containsIgnoreCase(message, "mechansims") ||
                    StringUtils.containsIgnoreCase(message, "mechanisms")) {
                return i18nService.createKeyedMessage("bitbucket.service.mail.no.mechanisms");
            }
        }
        return getDefaultAuthenticationErrorMessage();
    }

    private KeyedMessage getDefaultAuthenticationErrorMessage() {
        return i18nService.createKeyedMessage("bitbucket.service.mail.configurationerror");
    }

    private KeyedMessage getSendErrorMessage() {
        return i18nService.createKeyedMessage("bitbucket.service.mail.sendfail");
    }

    /**
     * Sets the number of <i>seconds</i> which establishing an SMTP connection is allowed to take before it times out.
     * <p>
     * This timeout is applied during normal execution, when using a configured sender, to send real messages.
     *
     * @param connectTimeout the connection timeout in seconds
     */
    @Value("${mail.timeout.connect}")
    public void setConnectTimeout(int connectTimeout) {
        this.connectTimeout = toMilliseconds(connectTimeout);
    }

    /**
     * Sets the number of <i>seconds</i> which sending an SMTP message is allowed to take before it times out.
     * <p>
     * This timeout is applied during normal execution, when using a configured sender, to send real messages.
     *
     * @param sendTimeout the send timeout in seconds
     */
    @Value("${mail.timeout.send}")
    public void setSendTimeout(int sendTimeout) {
        this.sendTimeout = toMilliseconds(sendTimeout);
    }

    /**
     * Sets the number of <i>seconds</i> which establishing an SMTP connection is allowed to take before it times out.
     * <p>
     * This timeout is used when configuring a sender while sending test messages. It may be desirable to configure this
     * for a shorter duration than {@link #setConnectTimeout(int) normal connections} to improve the responsiveness of
     * the configuration screen, since sending test messages happens in user time.
     *
     * @param testConnectTimeout the connection timeout in seconds
     */
    @Value("${mail.test.timeout.connect}")
    public void setTestConnectTimeout(int testConnectTimeout) {
        this.testConnectTimeout = toMilliseconds(testConnectTimeout);
    }

    /**
     * Sets the number of <i>seconds</i> which sending an SMTP message is allowed to take before it times out.
     * <p>
     * This timeout is used when configuring a sender while sending test messages. It may be desirable to configure this
     * for a shorter duration than {@link #setConnectTimeout(int) normal connections} to improve the responsiveness of
     * the configuration screen, since sending test messages happens in user time.
     *
     * @param testSendTimeout the send timeout in seconds
     */
    @Value("${mail.test.timeout.send}")
    public void setTestSendTimeout(int testSendTimeout) {
        this.testSendTimeout = toMilliseconds(testSendTimeout);
    }

    /**
     * Builds a {@code JavaMailSender} which will use the normal {@link #connectTimeout connection timeout} and
     * {@link #sendTimeout send timeout}.
     *
     * @param config the configuration to use
     * @return a configured mail sender
     * @see #buildMailSender(MailHostConfiguration, int, int)
     */
    private JavaMailSender buildMailSender(MailHostConfiguration config) {
        return buildMailSender(config, connectTimeout, sendTimeout);
    }

    /**
     * Builds a {@code JavaMailSender} which will use the provided {@code connectTimeout} and {@code sendTimeout}.
     *
     * @param config         the configuration to use
     * @param connectTimeout the millisecond timeout to apply to the underlying socket when connecting to the server
     * @param sendTimeout    the millisecond timeout to apply for I/O when sending a message
     * @return a configured mail sender
     */
    private JavaMailSender buildMailSender(MailHostConfiguration config, int connectTimeout, int sendTimeout) {
        return senderFactory.create(config, connectTimeout, sendTimeout);
    }

    /**
     * Returns a Spring {@code MimeMessagePreparator} which applies properties from the provided {@link MailMessage} to
     * the constructed {@code MimeMessage} before it is sent.
     * 
     * @param mailMessage the message containing properties to apply
     * @return a preparator which will apply the provided properties
     */
    private MimeMessagePreparator newMimeMailMessagePreparator(final MailMessage mailMessage) {
        return new MimeMessagePreparator() {
            @Override
            public void prepare(MimeMessage mimeMessage) throws Exception {
                MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, mailMessage.getAttachments().size() > 0);
                MimeMailMessage message = new MimeMailMessage(helper);
                message.setSubject(mailMessage.getSubject());
                message.setText(mailMessage.getText());

                //Use the MimeMailMessage for setting the address attributes as its interface
                //does validation. Only enable mime mode if there are attachments
                message.setTo(Iterables.toArray(mailMessage.getTo(), String.class));
                if (mailMessage.hasFrom()) {
                    message.setFrom(mailMessage.getFrom());
                } else {
                    InternetAddress from = new InternetAddress(applicationPropertiesService.getServerEmailAddress());
                    if (!StringUtils.isBlank(applicationPropertiesService.getDisplayName())) {
                        from.setPersonal(applicationPropertiesService.getDisplayName());
                    }
                    message.getMimeMessageHelper().setFrom(from);
                }
                if (mailMessage.hasCc()) {
                    message.setCc(Iterables.toArray(mailMessage.getCc(), String.class));
                }
                if (mailMessage.hasBcc()) {
                    message.setBcc(Iterables.toArray(mailMessage.getBcc(), String.class));
                }

                //Add attachments
                for (MailAttachment attachment : mailMessage.getAttachments()) {
                    helper.addAttachment(attachment.getFileName(), attachment.getSource());
                }

                //Add headers
                for (Map.Entry<String, String> header : mailMessage.getHeaders().entrySet()) {
                    mimeMessage.addHeader(header.getKey(), header.getValue());
                }
            }
        };
    }

    /**
     * Applies a millisecond conversion to the provided value expressed in seconds and casts the converted value back
     * to {@code int}.
     * <p>
     * Note: The properties for the standard {@code javax.mail} classes which control timeouts are typed as {@code int}
     * (they have to be {@code String} to be put in the map, but they are interpreted as {@code int}). This operation
     * will throw an exception if the value overflows an {@code int}.
     *
     * @param seconds the second value to interpret as milliseconds
     * @return the number of milliseconds for the provided number of seconds
     * @throws IllegalArgumentException Thrown if the number of milliseconds for the provided number of seconds
     *                                  overflows the {@code int} type.
     */
    private int toMilliseconds(int seconds) {
        return Ints.checkedCast(TimeUnit.SECONDS.toMillis(seconds));
    }
}
