package ru.yandex.webmaster3.worker.notifications.sending;

import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

import lombok.Setter;
import org.apache.commons.lang3.mutable.MutableObject;
import org.apache.commons.lang3.tuple.Pair;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import ru.yandex.webmaster3.core.WebmasterException;
import ru.yandex.webmaster3.core.http.WebmasterErrorResponse;
import ru.yandex.webmaster3.core.solomon.HandleCommonMetricsService;
import ru.yandex.webmaster3.core.solomon.Indicators;
import ru.yandex.webmaster3.core.solomon.SolomonSensor;
import ru.yandex.webmaster3.core.tracer.BeautyYdbTrace;
import ru.yandex.webmaster3.core.tracer.YdbTracer;
import ru.yandex.webmaster3.storage.notifications.NotificationChannel;
import ru.yandex.webmaster3.storage.notifications.NotificationProgress;
import ru.yandex.webmaster3.storage.notifications.NotificationRecListId;
import ru.yandex.webmaster3.storage.notifications.NotificationZKHelper;
import ru.yandex.webmaster3.storage.notifications.ProgressInfo;
import ru.yandex.webmaster3.storage.notifications.SendByChannelResult;
import ru.yandex.webmaster3.storage.notifications.dao.NotificationProgressCypressDao;
import ru.yandex.webmaster3.storage.notifications.dao.NotificationProgressYDao;
import ru.yandex.webmaster3.storage.util.clickhouse2.ClickhouseException;
import ru.yandex.webmaster3.storage.util.yt.YtException;
import ru.yandex.webmaster3.storage.util.yt.lock.CypressProvider;
import ru.yandex.webmaster3.storage.util.yt.lock.CypressProvider.Stat;

/**
 * @author avhaliullin
 */
public abstract class AbstractSendNotificationsTask<NotificationInfo, TargetInfo> {
    private static final Logger log = LoggerFactory.getLogger(AbstractSendNotificationsTask.class);

    private static final long RELOCK_IDLE_PERIOD = 10_000;
    private static final long RECHECK_LOCK_PERIOD = RELOCK_IDLE_PERIOD / 4;

    private static final long ALIGN_SECONDS = 1800;
    private static final String CATEGORY_LABEL_VALUE = "notifications";
    private static final String SUCCESS_PROCESSED_TYPE = "success_processed_chunks";
    private static final String FAIL_PROCESSED_TYPE = "fail_processed_chunks";
    private static final String NON_RELEASED_LOCKS_TYPE = "non_released_locks_type";


    private final List<Thread> threads = new ArrayList<>();
    @Setter
    private final String workerNamePrefix;
    @Setter
    protected int workerThreads = 1;

    @Setter
    private CypressProvider cypressProvider;
    @Setter
    private NotificationProgressCypressDao notificationProgressCypressDao;
    @Setter
    private NotificationProgressYDao notificationProgressYDao;
    @Autowired
    private HandleCommonMetricsService handleCommonMetricsService;

    private volatile boolean hasNotifications = false;

    protected AbstractSendNotificationsTask(String workerNamePrefix) {
        this.workerNamePrefix = workerNamePrefix;
    }

    public void init() {
        final AtomicInteger cntFailedChunks = new AtomicInteger(0);
        final AtomicInteger cntSuccessChunks = new AtomicInteger(0);
        final AtomicInteger cntNonReleasedLocks = new AtomicInteger(0);
        for (int i = 0; i < workerThreads; i++) {
            Thread t = new Thread(new NotifierThread(i == 0, cntFailedChunks, cntSuccessChunks, cntNonReleasedLocks), workerNamePrefix + i);
            t.setDaemon(true);
            threads.add(t);
            t.start();
        }
    }

    public void destroy() {
        for (Thread t : threads) {
            t.interrupt();
        }
    }

    /**
     * Проверяет, что нотификация готова к отправке в соостветствии с семантикой хранилища,
     * переключает состояние в хранилище (при необходимости), возвращает объект нотификации
     */
    protected abstract NotificationInfo getNotificationMarkStarted(UUID notificationId, int revisionId);

    /**
     * Можно ли удалить нотификацию из ZK
     */
    protected boolean canBeDeleted(UUID notificationId) {
        return false;
    }

    /**
     * Возвращает список адресатов
     */
    protected abstract List<TargetInfo> listTargets(NotificationRecListId listId, int fromOffset, int limit) throws ClickhouseException;

    /**
     * Посылает сообщение адресату
     */
    protected abstract boolean sendToTargetByChannel(NotificationInfo notificationInfo, TargetInfo targetInfo, NotificationChannel channel);

    /**
     * Вызывается после того как все сообщения в рамках нотификации были обработаны
     */
    protected abstract void finishNotification(UUID notificationId);

    protected abstract UUID getRecListId(NotificationInfo notificationInfo);

    protected abstract String extractTargetId(TargetInfo target);

    protected abstract Set<NotificationChannel> extractTargetChannels(TargetInfo target);

    private Set<Pair<String, NotificationChannel>> getProcessedInChunk(UUID notificationId, List<TargetInfo> targetInfos) {

        List<String> targetIds = targetInfos
                .stream()
                .map(AbstractSendNotificationsTask.this::extractTargetId)
                .collect(Collectors.toList());

        // получим инфу о прогрессе
        Map<String, List<ProgressInfo>> progressMap = notificationProgressYDao.getProgressInfo(notificationId, targetIds);

        // и оставим только обработанные пары (targetId, NotificationChannel)
        Set<Pair<String, NotificationChannel>> result = new HashSet<>();
        for (Map.Entry<String, List<ProgressInfo>> entry : progressMap.entrySet()) {
            for (ProgressInfo info : entry.getValue()) {
                if (info.getResult() != SendByChannelResult.NOT_PROCESSED) {
                    result.add(Pair.of(entry.getKey(), info.getChannel()));
                }
            }
        }

        return result;
    }

    private void storeProgress(UUID notificationId, String targetId, NotificationChannel channel, SendByChannelResult result) {
        notificationProgressYDao.saveResult(notificationId, targetId, channel, result);
    }


    public class NotifierThread implements Runnable {

        private final boolean main;
        private final AtomicInteger cntFailedChunks;
        private final AtomicInteger cntSuccessChunks;
        private final AtomicInteger cntNonReleasedLocks;
        private DateTime lastSendSolomon;

        public NotifierThread(boolean main, AtomicInteger cntFailedChunks, AtomicInteger cntSuccessChunks, AtomicInteger cntNonReleasedLocks) {
            this.main = main;
            this.cntFailedChunks = cntFailedChunks;
            this.cntSuccessChunks = cntSuccessChunks;
            this.cntNonReleasedLocks = cntNonReleasedLocks;
            lastSendSolomon = DateTime.now();
        }

        @Override
        public void run() {
            while (!Thread.interrupted()) {
                if (main || hasNotifications) {
                    try {
                        findAndProcessNotification(main);
                    } catch (Exception e) {
                        log.error("Send notifications task failed", e);
                        cntFailedChunks.incrementAndGet();
                    }
                }
                if (main && lastSendSolomon.plusMinutes(10).isBefore(DateTime.now())) {
                    final int successCount = cntSuccessChunks.getAndSet(0);
                    final int failedCount = cntFailedChunks.getAndSet(0);
                    final int nonReleasedLocksCount = cntNonReleasedLocks.getAndSet(0);
                    List<SolomonSensor> solomonSensors = new ArrayList<>();
                    solomonSensors.add(SolomonSensor.createAligned(ALIGN_SECONDS, successCount)
                            .withLabel(SolomonSensor.LABEL_CATEGORY, CATEGORY_LABEL_VALUE)
                            .withLabel(SolomonSensor.LABEL_DATA_TYPE, SUCCESS_PROCESSED_TYPE)
                            .withLabel(SolomonSensor.LABEL_INDICATOR, Indicators.COUNT));
                    solomonSensors.add(SolomonSensor.createAligned(ALIGN_SECONDS, failedCount)
                            .withLabel(SolomonSensor.LABEL_CATEGORY, CATEGORY_LABEL_VALUE)
                            .withLabel(SolomonSensor.LABEL_DATA_TYPE, FAIL_PROCESSED_TYPE)
                            .withLabel(SolomonSensor.LABEL_INDICATOR, Indicators.COUNT));
                    solomonSensors.add(SolomonSensor.createAligned(ALIGN_SECONDS, nonReleasedLocksCount)
                            .withLabel(SolomonSensor.LABEL_CATEGORY, CATEGORY_LABEL_VALUE)
                            .withLabel(SolomonSensor.LABEL_DATA_TYPE, NON_RELEASED_LOCKS_TYPE)
                            .withLabel(SolomonSensor.LABEL_INDICATOR, Indicators.COUNT));
                    handleCommonMetricsService.handle(solomonSensors, 400);

                    lastSendSolomon = DateTime.now();
                }

                try {
                    Thread.sleep(60_000);
                } catch (InterruptedException e) {
                    log.warn("Interrupted", e);
                    return;
                }
            }
        }

        private void findAndProcessNotification(boolean main) throws Exception {
            // пройдемся по списку нотификаций и найдем какой нибудь незавершенный чанк
            for (UUID notificationId : notificationProgressCypressDao.listRecords()) {
                NotificationProgress progressInfo = getProgress(notificationId, null);
                if (!progressInfo.isSending() || progressInfo.getChunksCount() == 0) {
                    // для этой нотификации все чанки обработаны (или их и не было вообще)
                    if (main && canBeDeleted(notificationId)) {
                        clearNotificationRecord(notificationId);
                    }
                } else {
                    if (progressInfo.getFinishedChunks().cardinality() >= progressInfo.getChunksCount() && progressInfo.getChunksCount() > 0) {
                        throw new RuntimeException("Should never happen: notification is in sending state, but all chunks are finished");
                    }
                    if (main) {
                        // signal to other threads about work
                        hasNotifications = true;
                    }
                    NotificationInfo notificationInfo = getNotificationMarkStarted(notificationId, progressInfo.getRevisionId());
                    LockedChunk lockedChunk = null;
                    do {
                        lockedChunk = processNotification(notificationId, progressInfo, notificationInfo, lockedChunk == null ? 0 :
                                lockedChunk.chunkId + 1);
                        cntSuccessChunks.incrementAndGet();
                    }
                    while (lockedChunk != null);
                    break;
                }
            }
            if (main) {
                hasNotifications = false;
            }
        }

        private LockedChunk processNotification(UUID notificationId, NotificationProgress progressInfo, NotificationInfo notificationInfo, int fromChunk) {
            log.info("processNotification: fromChunk = {}", fromChunk);
            String notificationNodePath = notificationNode(notificationId);
            int chunk = fromChunk;
            MutableObject<LockedChunk> result = new MutableObject<>();
            while (chunk < progressInfo.getChunksCount() && progressInfo.isSending()) {
                chunk = progressInfo.getFinishedChunks().nextClearBit(chunk);
                String lockPath = notificationNodePath + "/" + chunk;
                if (chunk >= progressInfo.getChunksCount()) {
                    return null;
                }
                // нашли незаконченный чанк
                int finalChunk = chunk;
                long nanos = System.nanoTime();
                boolean success = cypressProvider.tryRunExclusive(lockPath,  Duration.ofMillis(500L), Duration.ofMillis(100L), unused -> {
                    // Нужно убедиться, что кто-то шустрый не успел закончить нашу таску
                    NotificationProgress actualProgressInfo = getProgress(notificationId, null);
                    if (!actualProgressInfo.getFinishedChunks().get(finalChunk)) {
                        LockedChunk lockedChunk = new LockedChunk(notificationId, "", progressInfo.getRevisionId(),
                                progressInfo.getChunkSize() * finalChunk, progressInfo.getChunkSize(), finalChunk
                        );
                        try {
                            processChunk(lockedChunk, notificationInfo);
                            result.setValue(lockedChunk);
                        } catch (Exception e) {
                            log.error("Send notifications task failed", e);
                            cntFailedChunks.incrementAndGet();
                        }
                    }
                });
                if (success) {
                    cypressProvider.delete().forPath(lockPath);
                }
                log.info("tryRunExclusive time = {} ms", (System.nanoTime() - nanos) / 1000000);
                if (result.getValue() != null) {
                    return result.getValue();
                }
                chunk++;
            }
            return null;
        }

        private void processChunk(LockedChunk chunk, NotificationInfo notificationInfo) throws InterruptedException {
            YdbTracer.startTrace();
            log.info("Start processing chunk {} ", chunk);
            NotificationRecListId recListId = new NotificationRecListId(chunk.notificationId, getRecListId(notificationInfo));
            long extractTargetId = 0L;
            long storeProgress = 0L;
            long sendToTargetByChannel = 0L;
            long listTargets = 0L;
            long getProcessedInChunk = 0L;
            long completeChunk = 0L;


            long nanos = System.nanoTime();
            List<TargetInfo> chunkTargets = listTargets(recListId, chunk.fromOffset, chunk.chunkSize);
            listTargets = System.nanoTime() - nanos;
            nanos = System.nanoTime();
            Set<Pair<String, NotificationChannel>> processedInChunk = getProcessedInChunk(chunk.notificationId, chunkTargets);
            log.info("getProcessedInChunk time = {} ms", (System.nanoTime() - nanos) / 1000000);
            getProcessedInChunk = System.nanoTime() - nanos;
            log.info("Processed pairs user-channel count in chunk {} is {}", chunk, chunkTargets.size());


            for (TargetInfo target : chunkTargets) {
                for (NotificationChannel channel : extractTargetChannels(target)) {
                    nanos = System.nanoTime();
                    String targetId = extractTargetId(target);
                    extractTargetId += System.nanoTime() - nanos;
                    if (processedInChunk.contains(Pair.of(targetId, channel))) {
                        log.info("Skipping target {} channel {}", targetId, channel);
                        continue;
                    }

                    //nanos = System.nanoTime();
                    //storeProgress(chunk.notificationId, targetId, channel, SendByChannelResult.FAIL);
                    //storeProgress += System.nanoTime() - nanos;
                    nanos = System.nanoTime();
                    boolean wasSent = sendToTargetByChannel(notificationInfo, target, channel);
                    sendToTargetByChannel += System.nanoTime() - nanos;
                    if (!wasSent) {
                        nanos = System.nanoTime();
                        storeProgress(chunk.notificationId, targetId, channel, SendByChannelResult.NOT_PROCESSED);
                        storeProgress += System.nanoTime() - nanos;
                        throw new WebmasterException(
                                "Failed to send message - notification sender reported recoverable problem. " +
                                        "Chunk " + chunk + " target " + targetId + " channel " + channel,
                                new WebmasterErrorResponse.InternalUnknownErrorResponse(getClass(), null)
                        );
                    }
                    nanos = System.nanoTime();
                    storeProgress(chunk.notificationId, targetId, channel, SendByChannelResult.SENT);
                    storeProgress += System.nanoTime() - nanos;
                }
            }
            nanos = System.nanoTime();
            completeChunk(chunk);
            completeChunk += System.nanoTime() - nanos;
            log.info("Processed chunk trace: listTargets = {}, getProcessedInChunk = {}, extractTargetId = {}, storeProgress = {}, sendToTargetByChannel = {}, completeChunk = {}",
                    listTargets / 1000000, getProcessedInChunk / 1000000, extractTargetId / 1000000, storeProgress / 1000000, sendToTargetByChannel / 1000000, completeChunk / 1000000);
            log.info("Processed chunk {}", chunk);
            var ydbTrace = YdbTracer.stopTrace();
            BeautyYdbTrace beautyYdbTrace = ydbTrace != null ? new BeautyYdbTrace(ydbTrace) : null;
            log.info("Process chunk YDB trace: {}-{} {}", chunk.notificationId, chunk.chunkId, beautyYdbTrace);
        }
    }

    private void completeChunk(LockedChunk chunk) throws InterruptedException {
        String path = notificationNode(chunk);
        Stat stat = new Stat();
        while (!Thread.interrupted()) {
            NotificationProgress progress = getProgress(chunk.notificationId, stat);
            long now = System.currentTimeMillis();
            progress.getFinishedChunks().set(chunk.chunkId);
            progress.setLastChunkTs(now);
            if (progress.getFinishedChunks().cardinality() == progress.getChunksCount()) {
                // Мы сделали последнюю таску. Нужно перевести нотификашку в finished
                progress.setSending(false);
                finishNotification(chunk.notificationId);
            }
            if (progress.getFirstChunkAfterLastStartTs() < progress.getLastStarted()) {
                progress.setFirstChunkAfterLastStartTs(now);
            }
            try {
                cypressProvider
                        .setData()
                        .withVersion(stat.getVersion())
                        .forPath(path, NotificationZKHelper.dumpNotificationProgress(progress));
                return;
            } catch (YtException e) {
                log.warn("Failed to update " + chunk.notificationId + " state: high contention");
            }
        }
        throw new InterruptedException();
    }

    protected void clearNotificationRecord(UUID notificationId) {
        log.info("Removing old zk notification record {}", notificationId);
        notificationProgressCypressDao.deleteRecord(notificationId);
    }

    private NotificationProgress getProgress(UUID notificationId, Stat stat) {
        byte[] content = cypressProvider
                .getData()
                .storingStatIn(stat)
                .forPathBinary(notificationNode(notificationId));
        return NotificationZKHelper.getNotificationProgress(content);
    }

    private static class LockedChunk {
        final UUID notificationId;
        final String lockId;
        final int revisionId;
        final int fromOffset;
        final int chunkSize;
        final int chunkId;
        long lastChecked;

        public LockedChunk(UUID notificationId, String lockId, int revisionId, int fromOffset, int chunkSize, int chunkId) {
            this.notificationId = notificationId;
            this.lockId = lockId;
            this.revisionId = revisionId;
            this.fromOffset = fromOffset;
            this.chunkSize = chunkSize;
            this.chunkId = chunkId;
            this.lastChecked = System.currentTimeMillis();
        }

        @Override
        public String toString() {
            return "{notificationId=" + notificationId + ", chunkId=" + chunkId + ", revisionId=" + revisionId + '}';
        }
    }

    private String lockNode(LockedChunk chunk) {
        return notificationNode(chunk.notificationId) + "/" + chunk.chunkId;
    }

    private String notificationNode(UUID notificationId) {
        return notificationProgressCypressDao.getRootNode() + "/" + notificationId;
    }

    private String notificationNode(LockedChunk chunk) {
        return notificationNode(chunk.notificationId);
    }

}
