package ru.yandex.webmaster3.storage.host.moderation;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.google.common.collect.Lists;
import com.google.common.collect.Range;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Required;
import ru.yandex.webmaster3.core.WebmasterException;
import ru.yandex.webmaster3.core.http.WebmasterErrorResponse;
import ru.yandex.webmaster3.storage.util.yt.*;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * @author leonidrom
 */
public abstract class AbstractHostModerationYtService<Req extends AbstractHostModerationYtRequest, Res extends AbstractHostModerationYtResult> {
    protected static final Logger log = LoggerFactory.getLogger(AbstractHostModerationYtService.class);

    private static final int MAX_TABLE_SIZE = 100;
    private static final String YT_REQUEST_TABLE_PREFIX = "req-";
    private static final String YT_RESULT_TABLE_PREFIX = "req-";
    private static final String YT_AUTO_MODERATED_RESULT_TABLE_PREFIX = "auto-";
    private static final String YT_RESULT_ARCHIVE_DIR = "archive";
    private static final String YT_RESULT_ARCHIVE_TABLE = "merged_moderation_results";

    protected static final String AUTO_MODERATED_ASSESSOR = "auto";

    private JsonNode REQUESTS_TABLE_SCHEMA_JSON;
    private JsonNode RESULTS_TABLE_SCHEMA_JSON;

    protected static final ObjectMapper OM = new ObjectMapper()
            .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .enable(SerializationFeature.INDENT_OUTPUT);

    protected YtService ytService;
    private YtPath ytRequestsPath;
    private YtPath ytResultsPath;
    private YtPath ytResultsArchivePath;
    private YtPath ytResultsArchiveTablePath;

    public void init() {
        try {
            REQUESTS_TABLE_SCHEMA_JSON = OM.readTree(getRequestsTableSchema());
            RESULTS_TABLE_SCHEMA_JSON = OM.readTree(getResultsTableSchema());
        } catch (IOException e) {
            // shouldn't happen
            log.error("Exception while parsing table schema", e);
        }

        ytResultsArchivePath = YtPath.path(ytResultsPath, YT_RESULT_ARCHIVE_DIR);
        ytResultsArchiveTablePath = YtPath.path(ytResultsArchivePath, YT_RESULT_ARCHIVE_TABLE);
    }

    /**
     * Выгребает накопившиеся запросы на модерацию и выгружает их в таблички для Yt
     *
     * @return число запросов, отгруженных в Yt
     */
    public int processYtRequests() {
        List<Req> requestsToProcess = getRequestsToProcess();
        log.debug("Found {} requests to process", requestsToProcess.size());
        if (requestsToProcess.isEmpty()) {
            return 0;
        }

        createYtRequestTable(requestsToProcess);
        deleteProcessedYtRequests(requestsToProcess.stream().map(Req::getRequestId).collect(Collectors.toList()));

        return 0;
    }

    /**
     * Получает результаты модерации от Янга и кладет их в нашу базу
     *
     * @return число полученных результатов
     */
    public int processYtResults() {
        int totalResults = 0;
        List<YtPath> resultsToProcess = getResultsToProcess();
        List<YtPath> processedTables = new ArrayList<>();
        log.info("Result tables to process: {}", Arrays.toString(resultsToProcess.toArray()));

        for (YtPath resultPath : resultsToProcess) {
            try {
                // Результаты автомодерации уже в базе, мы просто хотим добавить их
                // в архивную Yt табличку. Поэтому мы их просто пропускаем.
                if (!resultPath.getName().startsWith(YT_AUTO_MODERATED_RESULT_TABLE_PREFIX)) {
                    totalResults += processYtResultTable(resultPath);
                }

                processedTables.add(resultPath);
            } catch (WebmasterException e) {
                // мы не хотим, чтобы одна проблемная табличка результатов
                // навсегда блокировала обработку всех остальных табличек
                log.error("Failed to process Yt results table", e);
            }
        }

        try {
            mergeAndRemoveResultTables(processedTables);
        } catch (YtException e) {
            throw new WebmasterException("Failed to merge/remove YT results table",
                    new WebmasterErrorResponse.YTServiceErrorResponse(getClass(), e), e);
        }

        return totalResults;
    }

    /**
     * Возвращает список запросов к Yt, которые накопились с того момента,
     * когда мы их обрабатывали в последний раз.
     */
    private List<Req> getRequestsToProcess() {
        Set<UUID> processedRequests = getProcessedRequestIds();
        log.info("Read {} processed requests IDs", processedRequests.size());

        return listAllRequests().stream()
                .filter(r -> !processedRequests.contains(r.getRequestId()))
                .collect(Collectors.toList());
    }

    /**
     * Возвращает список табличек с результатами модерации, которые нужно обработать
     */
    private List<YtPath> getResultsToProcess() {
        try {
            return listAllResultTables().stream()
                    .filter(t -> t.getName().startsWith(YT_RESULT_TABLE_PREFIX) ||
                            t.getName().startsWith(YT_AUTO_MODERATED_RESULT_TABLE_PREFIX))
                    .sorted(Comparator.comparing(YtPath::getName))
                    .collect(Collectors.toList());
        } catch (YtException | InterruptedException e) {
            throw new WebmasterException("Failed to obtain list of results to process",
                    new WebmasterErrorResponse.YTServiceErrorResponse(getClass(), e));
        }
    }

    /**
     * Возвращает все таблички из папки с результатами
     */
    private List<YtPath> listAllResultTables() throws YtException, InterruptedException {
        return ytService.withoutTransactionQuery(cypressService -> {
            return cypressService.list(ytResultsPath);
        });
    }

    /**
     * Создает Yt таблички для запросов модерации.
     */
    private void createYtRequestTable(Collection<Req> requests) {
        if (requests.isEmpty()) {
            return;
        }

        try {
            ytService.inTransaction(ytRequestsPath).execute(cypressService -> {
                List<Req> inProgress = requests.stream()
                        .filter(r -> !r.isAutoModerated())
                        .collect(Collectors.toList());

                if (!inProgress.isEmpty()) {
                    var batchedReqs = Lists.partition(inProgress, MAX_TABLE_SIZE);
                    for (List<Req> batch : batchedReqs) {
                        long ts = System.currentTimeMillis();
                        YtPath tablePath = YtPath.path(ytRequestsPath, YT_REQUEST_TABLE_PREFIX + ts);
                        uploadRequestsToYt(cypressService, tablePath, batch);
                    }
                }

                List<Req> autoModerated = requests.stream()
                        .filter(r -> r.isAutoModerated())
                        .collect(Collectors.toList());

                if (!autoModerated.isEmpty()) {
                    long ts = System.currentTimeMillis();
                    YtPath tablePath = YtPath.path(ytResultsPath, YT_AUTO_MODERATED_RESULT_TABLE_PREFIX + ts);
                    uploadAutoModerationResultsToYt(cypressService, tablePath, autoModerated);
                }

                return true;
            });
        } catch (YtException e) {
            throw new WebmasterException("Failed to create YT request table",
                    new WebmasterErrorResponse.YTServiceErrorResponse(getClass(), e), e);
        }
    }

    private void uploadRequestsToYt(YtCypressService cypressService, YtPath tablePath, Collection<Req> requests) {
        cypressService.writeTable(tablePath, tw -> {
            try {
                YtNodeAttributes attributes = new YtNodeAttributes();
                attributes.getAttributes().put("schema", REQUESTS_TABLE_SCHEMA_JSON);
                cypressService.create(tablePath, YtNode.NodeType.TABLE, true, attributes, true);

                for (Req request : requests) {
                    writeRequestColumns(tw, request);
                    tw.rowEnd();
                }
            } catch (YtException | JsonProcessingException e) {
                throw new RuntimeException("Unable to upload request to Yt");
            }
        });
    }

    private void uploadAutoModerationResultsToYt(YtCypressService cypressService, YtPath tablePath, Collection<Req> requests) {
        cypressService.writeTable(tablePath, tw -> {
            try {
                YtNodeAttributes attributes = new YtNodeAttributes();
                attributes.getAttributes().put("schema", RESULTS_TABLE_SCHEMA_JSON);
                cypressService.create(tablePath, YtNode.NodeType.TABLE, true, attributes, true);

                for (Req request : requests) {
                    writeRequestColumns(tw, request);
                    writeAutoModerationResultColumns(tw, request);

                    tw.rowEnd();
                }
            } catch (YtException | JsonProcessingException e) {
                throw new RuntimeException("Unable to upload request to Yt");
            }
        });
    }

    private int processYtResultTable(YtPath tablePath) {
        log.info("Processing results from {}", tablePath);

        List<Res> rows = new ArrayList<>();
        try {
            ytService.withoutTransaction(cypressService -> {
                readYtResultTable(cypressService, tablePath, rows);
                return true;
            });

            if (rows.isEmpty()) {
                throw new WebmasterException("Failed to read Yt result table (no data)",
                        new WebmasterErrorResponse.DataConsistencyErrorResponse(getClass(), "No data in Yt result table"));
            }
        } catch (YtException | InterruptedException e) {
            throw new WebmasterException("Failed to read YT results table",
                    new WebmasterErrorResponse.YTServiceErrorResponse(getClass(), e), e);
        }

        for (Res row : rows) {
            processYtResult(row);
        }

        return rows.size();
    }

    /**
     * Вычитывает данные из Yt таблички результатов.
     */
    private void readYtResultTable(YtCypressService cypressService, YtPath tablePath, List<Res> outRows) throws YtException {
        YtTableReadDriver<Res> tableReadDriver = YtTableReadDriver.createYSONDriver(getYtResultClass());
        AsyncTableReader<Res> tableReader = new AsyncTableReader<>(cypressService, tablePath, Range.all(), tableReadDriver)
                .withRetry(5);

        try (AsyncTableReader.TableIterator<Res> it = tableReader.read()) {
            while (it.hasNext()) {
                outRows.add(it.next());
            }
        } catch (IOException | InterruptedException e) {
            throw new YtException("Unable to read table: " + tablePath, e);
        }
    }

    private void mergeAndRemoveResultTables(List<YtPath> tables) throws YtException {
        if (tables.isEmpty()) {
            return;
        }

        ytService.inTransaction(ytResultsArchivePath).withTimeout(3, TimeUnit.HOURS).execute(cypressService -> {
            List<YtPath> sourceTables = new ArrayList<>(tables);
            if (cypressService.exists(ytResultsArchiveTablePath)) {
                sourceTables.add(ytResultsArchiveTablePath);
            }

            // сливаем данные в общую табличку
            YtOperationId operationId = cypressService.sort(sourceTables, ytResultsArchiveTablePath,
                    CommonYtRequestFields.HOST, CommonYtRequestFields.CREATED_DATE);
            if (!cypressService.waitFor(operationId)) {
                throw new WebmasterException("Failed to wait for merge completion. OperationId = " + operationId,
                        new WebmasterErrorResponse.YTServiceErrorResponse(getClass(), null));
            }

            for (YtPath table : tables) {
                cypressService.remove(table);
            }

            return true;
        });
    }

    protected static class CommonYtRequestFields {
        public static final String HOST = "Host";
        public static final String REQUEST_ID = "RequestId";
        public static final String CREATED_DATE = "CreatedDate";
    }

    public static class CommonYtResultFields extends CommonYtRequestFields {
        public static final String ASSESSOR = "Assessor";
        public static final String MODERATION_DATE = "ModerationDate";
    }

    @Required
    public void setYtService(YtService ytService) {
        this.ytService = ytService;
    }

    @Required
    public void setYtRequestsPath(YtPath ytRequestsPath) {
        this.ytRequestsPath = ytRequestsPath;
    }

    @Required
    public void setYtResultsPath(YtPath ytResultsPath) {
        this.ytResultsPath = ytResultsPath;
    }

    protected abstract void deleteProcessedYtRequests(List<UUID> processedRequests) ;
    protected abstract Set<UUID> getProcessedRequestIds() ;
    protected abstract List<Req> listAllRequests() ;

    protected abstract void writeRequestColumns(TableWriter tw, Req req) throws YtException, JsonProcessingException;
    protected abstract void writeAutoModerationResultColumns(TableWriter tw, Req req) throws YtException;
    protected abstract void processYtResult(Res row);

    protected abstract Class<Res> getYtResultClass();
    protected abstract String getRequestsTableSchema();
    protected abstract String getResultsTableSchema();
}
