package ru.yandex.wmconsole.notifier.sender;

import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.transaction.TransactionStatus;

import ru.yandex.wmconsole.data.NotificationChannelEnum;
import ru.yandex.wmconsole.data.NotificationTypeEnum;
import ru.yandex.wmconsole.data.NotificationsForUser;
import ru.yandex.wmconsole.data.UserNotificationOptions;
import ru.yandex.wmconsole.data.info.NotificationInfo;
import ru.yandex.wmconsole.data.info.NotificationOptionInfo;
import ru.yandex.wmconsole.data.partition.WMCPartition;
import ru.yandex.wmconsole.notifier.provider.strategy.SendStrategy;
import ru.yandex.wmconsole.service.NotificationOptionsService;
import ru.yandex.wmconsole.service.NotificationSenderService;
import ru.yandex.wmtools.common.error.InternalException;
import ru.yandex.wmtools.common.error.UserException;
import ru.yandex.wmtools.common.service.JdbcConnectionWrapperService;
import ru.yandex.wmtools.common.util.ServiceTransactionCallbackWithoutResult;
import ru.yandex.wmtools.common.util.SqlUtil;

/**
 * Sends all valid notifications via all valid channels, grouped by user.
 * Notification is valid if it was not sent earlier.
 * Channel is valid if user has this channel option turned on.
 * If notification fails to send via some channel, a fail message is added to the database.
 * After notifications are sent they are marked sent in the database.
 *
 * @author Andrey Mima (amima@yandex-team.ru)
 */
public class NotificationSender {
    private static final Logger log = LoggerFactory.getLogger(NotificationSender.class);

    private static final String INSERT_FAILS_QUERY =
            "INSERT INTO " +
                        "tbl_notification_fails (notification_id, channel) " +
                    "VALUES " +
                        "%s";

    private static final String DELETE_SUCCESS_NOTIFICATION_QUERY =
            "DELETE FROM " +
                        "tbl_notifications " +
                    "WHERE " +
                        "notification_id IN (%s)";

    private static final String UPDATE_FAIL_NOTIFICATION_QUERY =
            "UPDATE " +
                        "tbl_notifications " +
                    "SET " +
                        "progress_started = NULL " +
                    "WHERE " +
                        "notification_id IN (%s)";

    private JdbcConnectionWrapperService jdbcConnectionWrapperService;
    private NotificationSenderService notificationService;
    private NotificationOptionsService notificationOptionsService;

    private Map<NotificationChannelEnum, Sender> senders;
    protected Map<NotificationTypeEnum, SendStrategy> strategies;

    public void sendAll() throws UserException, InternalException {
        // set progress_started
        Collection<NotificationInfo> notifications = notificationService.getUnnotifiedNotifications();

        log.debug("Got " + notifications.size() + " notifications to send");

        Map<NotificationInfo, SentNotification> sentNotifications = sendNotifications(notifications);

        // delete from tbl_notifications
        processNotified(sentNotifications);
        // insert into tbl_notification_fails
        processPartiallySent(sentNotifications);
        // set tbl.notifications.progress_started=null
        processFailed(sentNotifications);
    }

    private Map<NotificationInfo, SentNotification> sendNotifications(Collection<NotificationInfo> notifications) {
        Map<NotificationInfo, SentNotification> sentNotifications = new HashMap<NotificationInfo, SentNotification>();

        //Groups all notifications by user
        Map<Long, NotificationsForUser> userNotifications = getUserNotifications(notifications);

        log.debug("Grouped " + userNotifications.size() + " users with notifications");

        //For all users
        for (NotificationsForUser notificationsForUser : userNotifications.values()) {
            try {
                //Learn notification options of user
                Long userId = notificationsForUser.getUserId();
                UserNotificationOptions userNotificationOptions =
                        notificationOptionsService.getUserNotificationOptions(userId);

                for (NotificationOptionInfo info: userNotificationOptions.getNotificationOptions()) {
                    log.debug("option type=" + info.getNotificationType() + " channel=" + info.getNotificationChannel());
                }

                //Send only to allowed types and channels, always process internal notifications
                for (Integer type : notificationsForUser.getTypes()) {
                    for (NotificationChannelEnum channel : NotificationChannelEnum.values()) {
                        if (userNotificationOptions.containsOption(type, channel) ||
                                (userId == 0 && channel == NotificationChannelEnum.INTERNAL)) {
                            log.debug("sendNotificationByChannel uid="+userId+" type="+type+" channel="+channel);

                            sendNotificationsByChannel(NotificationsForUser.getCopy(notificationsForUser), channel,
                                    type, sentNotifications);
                        }
                    }
                }
            } catch (InternalException e) {
                log.error("InternalException in " + getClass().getName() +
                        " while attempting to get user notification options", e);
            }
        }

        log.debug("Notifications sent");

        return sentNotifications;
    }

    private void putKeyIfNotExist(Map<NotificationInfo, SentNotification> sentNotifications,
                                  NotificationInfo notification) {
        if (!sentNotifications.containsKey(notification)) {
            sentNotifications.put(notification, new SentNotification(notification));
        }
    }

    /**
     * Groups notifications by users
     *
     * @param notifications notifications to group by user
     * @return map from user id to user notifications
     */
    private Map<Long, NotificationsForUser> getUserNotifications(Collection<NotificationInfo> notifications) {
        Map<Long, NotificationsForUser> userNotifications = new HashMap<Long, NotificationsForUser>();

        for (NotificationInfo notification : notifications) {
            if (!userNotifications.containsKey(notification.getUserId())) {
                userNotifications.put(notification.getUserId(), new NotificationsForUser(notification.getUserId()));
            }

            userNotifications.get(notification.getUserId()).addNotification(notification);
        }

        return userNotifications;
    }

    private void sendNotificationsByChannel(NotificationsForUser notificationsForUser,
                                            NotificationChannelEnum channel, Integer type,
                                            Map<NotificationInfo, SentNotification> sentNotifications) {
        if (!notificationsForUser.isEmpty()) {
            //Send notifications by current channel, knowing type, and handle success/fail
            try {
                log.debug("Sending to user id: " + notificationsForUser.getUserId().intValue() +
                        " channel: " + channel.toString() +
                        " type: " + type.toString());

                SendStrategy strategy;
                if (type >= 1024) {
                    strategy = strategies.get(NotificationTypeEnum.HOST_OWNERS);
                } else {
                    strategy = strategies.get(NotificationTypeEnum.R.fromValueOrNull(type));
                }
                List<NotificationInfo> typeNotifications = notificationsForUser.getNotifications(type);
                List<NotificationInfo> notificationsPortion = strategy.getSendPortion(typeNotifications);
                NotificationsForUser portionForUser = new NotificationsForUser(notificationsForUser.getUserId());
                portionForUser.addAll(notificationsPortion);
                while (!notificationsPortion.isEmpty()) {
                    if (!senders.get(channel).sendPortionToUser(portionForUser, type)) {
                        handleFail(portionForUser, channel, type, sentNotifications);
                    } else {
                        handleSuccess(portionForUser, channel, type, sentNotifications);
                    }
                    notificationsPortion = strategy.getSendPortion(typeNotifications);
                    portionForUser = new NotificationsForUser(notificationsForUser.getUserId());
                    portionForUser.addAll(notificationsPortion);
                }
            } catch (RuntimeException e) {
                log.error("Exception while sending notifications to user by channel " + channel.getValue() +
                        " of type " + type, e);
                handleFail(notificationsForUser, channel, type, sentNotifications);
            }
        }
    }

    private void handleSuccess(NotificationsForUser notificationsForUser, NotificationChannelEnum channel, Integer type,
                               Map<NotificationInfo, SentNotification> sentNotifications) {
        for (NotificationInfo notification : notificationsForUser.getNotifications(type)) {
            putKeyIfNotExist(sentNotifications, notification);
            log.debug("Succeeded notification " + notification.getNotificationId() +
                    " of type " + notification.getNotificationType() +
                    " by channel " + channel +
                    " to user " + notification.getUserId());
            if (!sentNotifications.get(notification).getFailed().contains(channel)) {
                sentNotifications.get(notification).addSucceeded(channel);
            }
        }
    }

    private void handleFail(NotificationsForUser notificationsForUser, NotificationChannelEnum channel, Integer type,
                            Map<NotificationInfo, SentNotification> sentNotifications) {
        for (NotificationInfo notification : notificationsForUser.getNotifications(type)) {
            putKeyIfNotExist(sentNotifications, notification);
            log.debug("Failed notification " + notification.getNotificationId() +
                    " of type " + notification.getNotificationType() +
                    " by channel " + channel.name() +
                    " to user " + notification.getUserId());
            if (!sentNotifications.get(notification).getSucceeded().contains(channel)) {
                sentNotifications.get(notification).addFailed(channel);
            }
        }
    }

    private void processNotified(Map<NotificationInfo, SentNotification> sentNotifications)
            throws UserException, InternalException {
        final int maxListSize = 50000;

        if (sentNotifications.isEmpty()) {
            return;
        }

        final List<Long> sentIds = new ArrayList<Long>();

        for (SentNotification sentNotification : sentNotifications.values()) {
            if (sentNotification.areAllSent()) {
                sentIds.add(sentNotification.getNotification().getNotificationId());
            }
        }

        jdbcConnectionWrapperService.getServiceTransactionTemplate(WMCPartition.nullPartition()).executeInService(
                new ServiceTransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) throws InternalException {
                int index = 0;
                List<Long> subList;

                while (index < sentIds.size()) {
                    if (index + maxListSize < sentIds.size()) {
                        subList = sentIds.subList(index, index + maxListSize);
                        index += maxListSize;
                    } else {
                        subList = sentIds.subList(index, sentIds.size());
                        index += sentIds.size();
                    }

                    if (!subList.isEmpty()) {
                        String sentString = SqlUtil.getCommaSeparatedList(sentIds);
                        String succeedQuery = String.format(DELETE_SUCCESS_NOTIFICATION_QUERY, sentString);
                        jdbcConnectionWrapperService.getJdbcTemplate(WMCPartition.nullPartition()).update(succeedQuery);
                    }
                }
            }
        });
    }

    private void processPartiallySent(Map<NotificationInfo, SentNotification> sentNotifications)
            throws UserException, InternalException {
        final int maxListSize = 10000;

        class SentPair {
            private final Long notificationId;
            private final Integer channel;

            public SentPair(Long notificationId, Integer channel) {
                this.notificationId = notificationId;
                this.channel = channel;
            }

            public Long getNotificationId() {
                return notificationId;
            }

            public Integer getChannel() {
                return channel;
            }
        }

        if (sentNotifications.isEmpty()) {
            return;
        }

        final List<SentPair> sentPairs = new ArrayList<SentPair>();

        for (SentNotification sentNotification : sentNotifications.values()) {
            if (!sentNotification.areAllSent()) {
                for (NotificationChannelEnum failedChannel : sentNotification.getFailed()) {
                    sentPairs.add(
                            new SentPair(sentNotification.getNotification().getNotificationId(),
                                    failedChannel.getValue())
                    );
                }
            }
        }

        jdbcConnectionWrapperService.getServiceTransactionTemplate(WMCPartition.nullPartition()).executeInService(
                new ServiceTransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) throws InternalException {
                SqlUtil.ListParameterizer<SentPair> pairParameterizer = new SqlUtil.ListParameterizer<SentPair>() {
                    @Override
                    public String getParameter(int i, SentPair sentPair) {
                        if (i == 0) {
                            return sentPair.getNotificationId().toString();
                        } else if (i == 1) {
                            return sentPair.getChannel().toString();
                        } else {
                            throw new IllegalArgumentException("Insert fails must have only param indexes 0 or 1");
                        }
                    }

                    @Override
                    public int getParamNumber() {
                        return 2;
                    }
                };

                int index = 0;
                List<SentPair> subList;

                while (index < sentPairs.size()) {
                    if (index + maxListSize < sentPairs.size()) {
                        subList = sentPairs.subList(index, index + maxListSize);
                        index += maxListSize;
                    } else {
                        subList = sentPairs.subList(index, sentPairs.size());
                        index += sentPairs.size();
                    }

                    if (!subList.isEmpty()) {
                        String fails = SqlUtil.getCommaSeparatedList(sentPairs, pairParameterizer);
                        String insertFailsQuery = String.format(INSERT_FAILS_QUERY, fails);
                        jdbcConnectionWrapperService.getJdbcTemplate(WMCPartition.nullPartition()).update(insertFailsQuery);
                    }
                }
            }
        });
    }

    private void processFailed(Map<NotificationInfo, SentNotification> sentNotifications)
            throws UserException, InternalException {
        final int maxListSize = 50000;

        if (sentNotifications.isEmpty()) {
            return;
        }

        final List<Long> sentIds = new ArrayList<Long>();

        for (SentNotification sentNotification : sentNotifications.values()) {
            if (sentNotification.areAllFailed()) {
                sentIds.add(sentNotification.getNotification().getNotificationId());
            }
        }

        jdbcConnectionWrapperService.getServiceTransactionTemplate(WMCPartition.nullPartition()).executeInService(
                new ServiceTransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) throws InternalException {
                int index = 0;
                List<Long> subList;

                while (index < sentIds.size()) {
                    if (index + maxListSize < sentIds.size()) {
                        subList = sentIds.subList(index, index + maxListSize);
                        index += maxListSize;
                    } else {
                        subList = sentIds.subList(index, sentIds.size());
                        index += sentIds.size();
                    }

                    if (!subList.isEmpty()) {
                        String sentString = SqlUtil.getCommaSeparatedList(sentIds);
                        String failUpdateQuery = String.format(UPDATE_FAIL_NOTIFICATION_QUERY, sentString);
                        jdbcConnectionWrapperService.getJdbcTemplate(WMCPartition.nullPartition()).update(failUpdateQuery);
                    }
                }
            }
        });
    }

//    public void cleanup() throws InternalException {
//        notificationService.cleanup();
//    }

    private class SentNotification {
        private final NotificationInfo notification;
        private final List<NotificationChannelEnum> succeededChannels = new ArrayList<NotificationChannelEnum>();
        private final List<NotificationChannelEnum> failedChannels = new ArrayList<NotificationChannelEnum>();

        public SentNotification(NotificationInfo notification) {
            this.notification = notification;
        }

        public void addFailed(NotificationChannelEnum channel) {
            failedChannels.add(channel);
        }

        public void addAllFailed(List<NotificationChannelEnum> channels) {
            failedChannels.addAll(channels);
        }

        public void addSucceeded(NotificationChannelEnum channel) {
            succeededChannels.add(channel);
        }

        public NotificationInfo getNotification() {
            return notification;
        }

        public List<NotificationChannelEnum> getFailed() {
            return failedChannels;
        }

        public List<NotificationChannelEnum> getSucceeded() {
            return succeededChannels;
        }

        public boolean areAllSent() {
            return (failedChannels.size() == 0);
        }

        public boolean isAtLeastOneSent() {
            return (succeededChannels.size() > 0);
        }

        public boolean areAllFailed() {
            return (succeededChannels.size() == 0);
        }
    }

    @Required
    public void setSenders(Map<NotificationChannelEnum, Sender> senders) {
        this.senders = new EnumMap<NotificationChannelEnum, Sender>(senders);
    }

    @Required
    public void setStrategies(Map<NotificationTypeEnum, SendStrategy> strategies) {
        this.strategies =  new EnumMap<NotificationTypeEnum, SendStrategy>(strategies);
    }

    @Required
    public void setJdbcConnectionWrapperService(JdbcConnectionWrapperService jdbcConnectionWrapperService) {
        this.jdbcConnectionWrapperService = jdbcConnectionWrapperService;
    }

    @Required
    public void setNotificationSenderService(NotificationSenderService notificationService) {
        this.notificationService = notificationService;
    }

    @Required
    public void setNotificationOptionsService(NotificationOptionsService notificationOptionsService) {
        this.notificationOptionsService = notificationOptionsService;
    }

}
