package ru.yandex.webmaster3.worker.queue;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

import lombok.Builder;
import lombok.Data;
import lombok.Value;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;

import ru.yandex.webmaster3.core.metrics.MonitoringCategoryUtil;
import ru.yandex.webmaster3.core.solomon.metric.SolomonCounter;
import ru.yandex.webmaster3.core.solomon.metric.SolomonGauge;
import ru.yandex.webmaster3.core.solomon.metric.SolomonKey;
import ru.yandex.webmaster3.core.solomon.metric.SolomonMetricConfiguration;
import ru.yandex.webmaster3.core.solomon.metric.SolomonMetricRegistry;
import ru.yandex.webmaster3.core.solomon.metric.SolomonTimer;
import ru.yandex.webmaster3.core.solomon.metric.SolomonTimerConfiguration;
import ru.yandex.webmaster3.core.worker.task.TaskResult;
import ru.yandex.webmaster3.core.worker.task.WorkerTaskType;
import ru.yandex.webmaster3.worker.RpsLimitedTask;
import ru.yandex.webmaster3.worker.Task;
import ru.yandex.webmaster3.worker.TaskRegistry;

/**
 * @author aherman
 */
public class TaskQueueMetrics {
    private static final int WINDOW_SIZE = 100;
    private static final long NANOS_IN_SECOND = TimeUnit.SECONDS.toNanos(1);

    private static final String SOLOMON_LABEL_TASK_RESULT = "result";
    private static final String SOLOMON_LABEL_TASK_TYPE = "task";
    private static final String SOLOMON_LABEL_TASK_CATEGORY = "category";
    private static final String SOLOMON_LABEL_QUEUE_TYPE = "queue";
    private static final String SOLOMON_LABEL_QUEUE_VALUE_COMMITED = "commited";
    private static final String SOLOMON_LABEL_QUEUE_VALUE_ROLLOVER = "rollover";
    private static final String SOLOMON_LABEL_QUEUE_VALUE_PROCESSED = "processed";
    private final TaskHostStatistics taskHostStatistics;
    private final TaskRegistry taskRegistry;
    private final SolomonMetricRegistry solomonMetricRegistry;
    private SolomonTimerConfiguration solomonTimerConfiguration;

    @Autowired
    public TaskQueueMetrics(
            TaskHostStatistics taskHostStatistics,
            TaskRegistry taskRegistry,
            SolomonMetricRegistry solomonMetricRegistry) {
        this.taskHostStatistics = taskHostStatistics;
        this.taskRegistry = taskRegistry;
        this.solomonMetricRegistry = solomonMetricRegistry;
    }

    private ConcurrentSkipListSet<String> startedWorkers = new ConcurrentSkipListSet<>();
    private ConcurrentSkipListSet<String> stoppedWorkers = new ConcurrentSkipListSet<>();

    private final ConcurrentHashMap<String, WorkerMetrics> workerMetrics = new ConcurrentHashMap<>();
    private Map<WorkerTaskType, TaskMetrics> taskMetrics = new EnumMap<>(WorkerTaskType.class);
    private QueueMetrics queueMetric = null;

    private final Lock lock = new ReentrantLock();
    private final ArrayDeque<Long> allTasksFinishTime = new ArrayDeque<>(WINDOW_SIZE);

    public void init() {
        this.taskMetrics = taskRegistry.getTaskRegistryMap().keySet().stream()
                .collect(Collectors.toMap(k -> k, type -> {
                    SolomonKey baseKey = SolomonKey.create(SOLOMON_LABEL_TASK_TYPE, type.name());
                    Map<TaskResult, SolomonTimer> solomonCounters = new EnumMap<>(TaskResult.class);
                    for (TaskResult result : TaskResult.values()) {
                        String category = MonitoringCategoryUtil.getCategory(taskRegistry.getTaskRegistryMap().get(type).getClass()).orElse("<unknown>");
                        solomonCounters.put(result, solomonMetricRegistry.createTimer(
                                solomonTimerConfiguration,
                                baseKey.withLabel(SOLOMON_LABEL_TASK_RESULT, result.name())
                                        .withLabel(SOLOMON_LABEL_TASK_CATEGORY, category)
                        ));
                    }
                    SolomonGauge<Long> enqueuedGauge = solomonMetricRegistry.createGauge(
                            baseKey.withLabel(SolomonKey.LABEL_INDICATOR, "tasks_enqueued")
                    );
                    return new TaskMetrics(solomonCounters, enqueuedGauge, WINDOW_SIZE);
                }));
        this.taskMetrics.forEach((type, taskMetrics) -> taskMetrics.updateStats());
        final SolomonMetricConfiguration solomonMetricConfiguration = new SolomonMetricConfiguration();
        final SolomonCounter commitedCounter = solomonMetricRegistry.createCounter(solomonMetricConfiguration,
                SolomonKey.create(SOLOMON_LABEL_QUEUE_TYPE, SOLOMON_LABEL_QUEUE_VALUE_COMMITED).withoutLabel("count"));
        final SolomonCounter rolloverCounter = solomonMetricRegistry.createCounter(solomonMetricConfiguration,
                SolomonKey.create(SOLOMON_LABEL_QUEUE_TYPE, SOLOMON_LABEL_QUEUE_VALUE_ROLLOVER).withoutLabel("count"));
        final SolomonCounter readCounter = solomonMetricRegistry.createCounter(solomonMetricConfiguration,
                SolomonKey.create(SOLOMON_LABEL_QUEUE_TYPE, SOLOMON_LABEL_QUEUE_VALUE_PROCESSED).withoutLabel("count"));
        queueMetric = new QueueMetrics(commitedCounter, rolloverCounter, readCounter);
    }

    public void workerStarted(String workerName) {
        startedWorkers.add(workerName);
        stoppedWorkers.remove(workerName);
        workerMetrics.put(workerName, new WorkerMetrics());
    }

    public void workerStopped(String workerName) {
        stoppedWorkers.add(workerName);
        startedWorkers.remove(workerName);
    }

    public void taskEnqueued(WorkerTaskType taskType) {
        taskMetrics.get(taskType).taskEnqueued();
    }

    public void taskPolled(TaskId taskId) {
        taskMetrics.get(taskId.getTaskType()).taskPolled();
    }

    public void taskPolled(WorkerTaskType taskType) {
        taskMetrics.get(taskType).taskPolled();
    }

    public void taskFinished(String workerName, TaskId taskId, long taskRunNanos, TaskResult result) {
        workerMetrics.get(workerName).incrementWorkTime(taskRunNanos);
        taskMetrics.get(taskId.getTaskType()).finished(result, taskRunNanos);
        if (taskId.getHostId() != null) {
            taskHostStatistics.recordHostTask(taskId.getHostId(), taskId.getTaskType());
        }
        lock.lock();
        if (allTasksFinishTime.size() > WINDOW_SIZE) {
            allTasksFinishTime.removeFirst();
        }
        allTasksFinishTime.addLast(System.nanoTime());
        lock.unlock();
    }

    public void setQueueSize(WorkerTaskType taskType, int queueSize) {
        taskMetrics.get(taskType).enqueued = queueSize;
    }

    public void queueCleared(WorkerTaskType type) {
        taskMetrics.get(type).queueCleared();
    }

    public List<WorkerStatistics> getWorkerStatistics() {
        List<WorkerStatistics> result = new ArrayList<>();
        for (Map.Entry<String, WorkerMetrics> entry : workerMetrics.entrySet()) {
            String workerName = entry.getKey();
            WorkerMetrics workerMetrics = entry.getValue();
            result.add(new WorkerStatistics(
                    workerName,
                    workerMetrics.getStartTime(),
                    workerMetrics.getWorkTime(),
                    workerMetrics.getTotalRunTime(),
                    !stoppedWorkers.contains(workerName) && startedWorkers.contains(workerName)
            ));
        }
        return result;
    }

    public void messageCommited() {
        queueMetric.batchCommited.update();
    }

    public void messageRollover() {
        queueMetric.rollover.update();
    }

    public void messageReaded() {
        queueMetric.batchReaded.update();
    }

    public TaskStatistics getTaskStatistics(WorkerTaskType taskType) {
        TaskMetrics tm = getTaskMetrics(taskType);
        if (tm == null) {
            return null;
        } else {
            Task task = taskRegistry.getTaskRegistryMap().get(taskType);
            Float targetRps = (task instanceof RpsLimitedTask) ? ((RpsLimitedTask) task).getTargetRps() : null;
            return TaskStatistics.builder()
                    .taskType(taskType)
                    .processing(tm.processing)
                    .enqueueed(tm.enqueued)
                    .successed(tm.succeeded)
                    .failed(tm.failed)
                    .averageRunTimsMs(tm.getMeanRunTimeMs())
                    .rps(tm.getRps())
                    .instantLaunchRps(tm.getInstantLaunchRps())
                    .targetRps(targetRps)
                    .build();
        }
    }

    public List<TaskStatistics> getTaskStatistics() {
        List<TaskStatistics> result = new ArrayList<>();
        for (Map.Entry<WorkerTaskType, TaskMetrics> entry : taskMetrics.entrySet()) {
            Task task = taskRegistry.getTaskRegistryMap().get(entry.getKey());
            Float targetRps = (task instanceof RpsLimitedTask) ? ((RpsLimitedTask) task).getTargetRps() : null;
            TaskMetrics tm = entry.getValue();

            var ts = TaskStatistics.builder()
                    .taskType(entry.getKey())
                    .processing(tm.processing)
                    .enqueueed(tm.enqueued)
                    .successed(tm.succeeded)
                    .failed(tm.failed)
                    .averageRunTimsMs(tm.getMeanRunTimeMs())
                    .rps(tm.getRps())
                    .instantLaunchRps(tm.getInstantLaunchRps())
                    .targetRps(targetRps)
                    .build();

            result.add(ts);
        }
        return result;
    }

    public TaskMetrics getTaskMetrics(WorkerTaskType type) {
        return taskMetrics.get(type);
    }

    public static float computeRpsOver5Min(ArrayDeque<Long> timePoints, long now) {
        Long oldestSelectedPoint = null;
        int pointsCount = 0;
        for (Long timePoint : timePoints) {
            long duration = now - timePoint;
            if (duration < 5 * 60 * NANOS_IN_SECOND) {
                if (oldestSelectedPoint == null) {
                    oldestSelectedPoint = timePoint;
                }
                pointsCount += 1;
            }
        }

        if (oldestSelectedPoint == null || now == oldestSelectedPoint) {
            return 0;
        } else {
            return 1.0f * pointsCount * NANOS_IN_SECOND / (now - oldestSelectedPoint);
        }
    }

    public static float computeInstantRps(ArrayDeque<Long> taskStartMoments, long now) {
        float result = 0;
        long newerThan = now - NANOS_IN_SECOND;
        Iterator<Long> it = taskStartMoments.descendingIterator();
        Long firstAfter1000ms = null;
        while (it.hasNext()) {
            Long previousStartTime = it.next();
            if (previousStartTime > newerThan) {
                result += 1.0;
            } else if (firstAfter1000ms == null) {
                firstAfter1000ms = previousStartTime;
            } else {
                it.remove();
            }
        }
        if (firstAfter1000ms != null && result == 0) {
            result = NANOS_IN_SECOND * 1.0f / (now - firstAfter1000ms);
        }
        return result;
    }

    public void setSolomonTimerConfiguration(SolomonTimerConfiguration solomonTimerConfiguration) {
        this.solomonTimerConfiguration = solomonTimerConfiguration;
    }

    static class WorkerMetrics {
        private final long startTimeMs = System.currentTimeMillis();
        private AtomicLong workTimeNanos = new AtomicLong();

        public Duration getTotalRunTime() {
            return Duration.millis(System.currentTimeMillis() - startTimeMs);
        }

        public Duration getWorkTime() {
            return Duration.millis(TimeUnit.NANOSECONDS.toMillis(workTimeNanos.get()));
        }

        public DateTime getStartTime() {
            return new DateTime(startTimeMs);
        }

        void incrementWorkTime(long nanos) {
            this.workTimeNanos.addAndGet(nanos);
        }
    }

    @Value
    static class QueueMetrics {
        private final SolomonCounter batchCommited;
        private final SolomonCounter rollover;
        private final SolomonCounter batchReaded;

    }

    static class TaskMetrics {
        private final Lock lock = new ReentrantLock();

        private long processing = 0;
        private long enqueued = 0;
        private long succeeded = 0;
        private long failed = 0;

        private final int windowSize;
        private final ArrayDeque<Long> pollTimesDuringLastSecond;
        private final ArrayDeque<Long> finishTimePoints;
        private final ArrayDeque<Long> lastRunTime;
        private final Map<TaskResult, SolomonTimer> solomonMetrics;
        private final SolomonGauge<Long> solomonEnqueuedMetric;

        TaskMetrics(Map<TaskResult, SolomonTimer> solomonMetrics,
                    SolomonGauge<Long> solomonEnqueuedMetric, int windowSize) {
            this.windowSize = windowSize;
            this.pollTimesDuringLastSecond = new ArrayDeque<>(windowSize);
            this.finishTimePoints = new ArrayDeque<>(windowSize);
            this.lastRunTime = new ArrayDeque<>(windowSize);
            this.solomonMetrics = solomonMetrics;
            this.solomonEnqueuedMetric = solomonEnqueuedMetric;
        }

        void taskEnqueued() {
            lock.lock();
            try {
                enqueued += 1;
                updateStats();
            } finally {
                lock.unlock();
            }
        }

        void queueCleared() {
            lock.lock();
            try {
                enqueued = 0;
                updateStats();
            } finally {
                lock.unlock();
            }
        }

        void taskPolled() {
            lock.lock();
            try {
                if (pollTimesDuringLastSecond.size() >= windowSize) {
                    pollTimesDuringLastSecond.removeFirst();
                }
                pollTimesDuringLastSecond.addLast(System.nanoTime());
                enqueued -= 1;
                processing += 1;
                updateStats();
            } finally {
                lock.unlock();
            }
        }

        public void finished(TaskResult taskResult, long runTimeNanos) {
            lock.lock();
            try {
                processing -= 1;
                if (taskResult == TaskResult.SUCCESS) {
                    succeeded += 1;
                } else {
                    failed += 1;
                }
                if (finishTimePoints.size() >= windowSize) {
                    finishTimePoints.removeFirst();
                }
                finishTimePoints.addLast(System.nanoTime());

                if (lastRunTime.size() >= windowSize) {
                    lastRunTime.removeFirst();
                }
                lastRunTime.addLast(runTimeNanos);

                updateStats();
            } finally {
                lock.unlock();
            }
            solomonMetrics.get(taskResult)
                    .update(Duration.millis(TimeUnit.NANOSECONDS.toMillis(runTimeNanos)));
        }

        long getMeanRunTimeMs() {
            long meanRunTimeNano = 0;
            lock.lock();
            try {
                if (lastRunTime.size() > 0) {
                    for (Long runTime : lastRunTime) {
                        meanRunTimeNano += runTime;
                    }
                    meanRunTimeNano /= lastRunTime.size();
                }
            } finally {
                lock.unlock();
            }
            return TimeUnit.NANOSECONDS.toMillis(meanRunTimeNano);
        }

        float getRps() {
            float result;
            lock.lock();
            try {
                result = computeRpsOver5Min(finishTimePoints, System.nanoTime());
            } finally {
                lock.unlock();
            }
            return result;
        }

        void updateStats() {
            solomonEnqueuedMetric.set(enqueued);
        }

        void updateStatsWithLock() {
            lock.lock();
            try {
                updateStats();
            } finally {
                lock.unlock();
            }
        }

        // Мгновенный RPS по временам запуска задач. Не умеет считать значения > WINDOW_SIZE - для текущих задач это не нужно
        float getInstantLaunchRps() {
            float result;
            lock.lock();
            try {
                long now = System.nanoTime();
                result = computeInstantRps(pollTimesDuringLastSecond, now);
            } finally {
                lock.unlock();
            }
            return result;
        }
    }

    @Data
    public static class WorkerStatistics {
        private final String name;
        private final DateTime startTime;
        private final Duration workDuration;
        private final Duration totalDuration;
        private final boolean running;
    }

    @Data
    @Builder
    public static class TaskStatistics {
        private final WorkerTaskType taskType;
        private final long processing;
        private final long enqueueed;
        private final long successed;
        private final long failed;
        private final long averageRunTimsMs;
        private final float rps;
        private final float instantLaunchRps;
        private final Float targetRps;
    }
}
