package ru.yandex.webmaster3.storage.util.yt;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.GZIPOutputStream;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.joda.JodaModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import lombok.Getter;
import org.apache.commons.io.IOUtils;
import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.context.SmartLifecycle;

import ru.yandex.webmaster3.core.http.HttpConstants;
import ru.yandex.webmaster3.core.http.WebmasterJsonModule;
import ru.yandex.webmaster3.core.util.functional.ThrowingConsumer;
import ru.yandex.webmaster3.core.util.yson.YsonMapper;

/**
 * @author aherman
 */
public class YtService implements SmartLifecycle {
    private static final Logger log = LoggerFactory.getLogger(YtService.class);

    private CloseableHttpClient httpClient;
    private File defaultCacheFolder;
    @Getter
    private Map<String, YtCluster> clusters = Collections.emptyMap();
    private YtTransactionService ytTransactionService;

    private int socketTimeoutMs = 60_000;

    static ObjectMapper OM = new ObjectMapper()
            .registerModule(new JodaModule())
            .registerModule(new ParameterNamesModule())
            .registerModule(new WebmasterJsonModule(false))
            .disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)
            .disable(SerializationFeature.WRITE_NULL_MAP_VALUES)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    static YsonMapper YSON_OM = new YsonMapper();

    static {
        YSON_OM.registerModule(new JodaModule())
                .disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)
                .disable(SerializationFeature.WRITE_NULL_MAP_VALUES);
    }

    private boolean running = false;

    private final YtCypressService defaultCypress;

    public YtService() {
        this.defaultCypress = new YtCypressServiceImpl(this, null);
    }

    @VisibleForTesting
    public YtService(YtCypressService ytCypressService) {
        this.defaultCypress = Preconditions.checkNotNull(ytCypressService);
    }

    public void init() {
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectTimeout(HttpConstants.DEFAULT_CONNECT_TIMEOUT)
                .setSocketTimeout(socketTimeoutMs)
                .setContentCompressionEnabled(false)
                .build();

        httpClient = HttpClients.custom()
                .setDefaultRequestConfig(requestConfig)
                .setConnectionTimeToLive(1, TimeUnit.MINUTES)
                .setMaxConnPerRoute(3)
                .setMaxConnTotal(clusters.size() * 3)
                .build();

        ytTransactionService = new YtTransactionService(this);

        for (YtCluster ytCluster : clusters.values()) {
            cleanFolder(ytCluster.getCacheFolder());
        }
        cleanFolder(defaultCacheFolder);
    }

    private void cleanFolder(File cacheFolder) {
        if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
            throw new RuntimeException("Unable to create YT cache folder: " + cacheFolder.getAbsolutePath());
        }

        File[] files = cacheFolder.listFiles();
        if (files == null) {
            return;
        }
        for (File file : files) {
            log.info("Clean old cache file: {}", file.getAbsolutePath());
            file.delete();
        }
    }

    @Override
    public void start() {
        ytTransactionService.start();
        running = true;
    }

    @Override
    public boolean isAutoStartup() {
        return true;
    }

    @Override
    public void stop(Runnable callback) {
        ytTransactionService.stop();
        callback.run();
    }

    @Override
    public void stop() {
    }

    @Override
    public boolean isRunning() {
        return running;
    }

    @Override
    public int getPhase() {
        return Integer.MAX_VALUE;
    }

    public void destroy() {
        IOUtils.closeQuietly(httpClient);
    }

    public void withoutTransaction(YtTransactionService.TransactionProcess process)
            throws YtException, InterruptedException {
        process.run(defaultCypress);
    }

    public <T> T withoutTransactionQuery(YtTransactionService.TransactionProcessWithResult<T> process)
            throws InterruptedException, YtException {
        return process.run(defaultCypress);
    }

    public YtTransactionService.TransactionBuilder inTransaction(String cluster) {
        return ytTransactionService.newTransactionBuilder(null, cluster);
    }


    public YtTransactionService.TransactionBuilder inTransaction(YtPath path) {
        return ytTransactionService.newTransactionBuilder(null, path.getCluster());
    }

    public <E extends Exception> YtTableData prepareTableData(String tableName, ThrowingConsumer<TableWriter, E> writerConsumer) throws YtException, E {
        File cacheFile = new File(defaultCacheFolder, "upload_" + tableName + "_" + System.currentTimeMillis());
        try (YtCypressServiceImpl.TableWriterImpl tw = new YtCypressServiceImpl.TableWriterImpl(
                new GZIPOutputStream(new BufferedOutputStream(new FileOutputStream(cacheFile))))) {
            writerConsumer.accept(tw);
        } catch (IOException e) {
            throw new YtException("Unable to write table", e);
        }
        return new YtTableData(cacheFile);
    }

    public YtTransactionService.TransactionBuilder inTransaction(String parentTransactionId, YtPath path) {
        return ytTransactionService.newTransactionBuilder(parentTransactionId, path.getCluster());
    }

    YtCluster getCluster(String clusterName) throws YtException {
        YtCluster cluster = clusters.get(clusterName);
        if (cluster == null) {
            throw new YtException("Unknown cluster: " + clusterName);
        }
        return cluster;
    }

    URI findHeavyProxyURI(YtCluster cluster) throws YtException {
        YtGetHeavyProxiesCommand command = new YtGetHeavyProxiesCommand();
        YtResult<List<URI>> ytResult = execute(null, cluster, cluster.getProxyUri(), command);
        if (ytResult.getStatus() != YtStatus.YT_200_OK) {
            throw new YtException("Unable to get heavy proxy urls " + ytResult.getStatus());
        } else if (ytResult.getResult().isEmpty()) {
            throw new YtException("Heavy proxy urls in empty");
        }

        List<URI> allUris = ytResult.getResult();
        List<URI> candidates = new ArrayList<>(allUris.subList(0, Math.min(allUris.size(), 5)));
        Collections.shuffle(candidates);
        return candidates.get(0);
    }

    <T> YtResult<T> execute(YtTransaction transaction, YtCluster cluster, YtCommand<T> command) throws YtException {
        if (command.needHeavyProxy()) {
            URI proxy = findHeavyProxyURI(cluster);
            return execute(transaction, cluster, proxy, command);
        } else {
            return execute(transaction, cluster, cluster.getProxyUri(), command);
        }
    }

    private <T> YtResult<T> execute(YtTransaction transaction, YtCluster cluster, URI proxyURI, YtCommand<T> command) throws YtException {
        if (transaction != null) {
            if (transaction.isClosed()) {
                throw new YtException("Transaction is already closed: " + transaction);
            }
        }

        log.trace("Yt command[{}]: {}", transaction != null ? transaction.getId() : "NONE", command);
        command.setTransactionId(transaction);
        command.setProxyUri(proxyURI);
        command.setAuthToken(cluster.getToken());
        HttpUriRequest request = null;
        try {
            request = command.createRequest();
        } catch (JsonProcessingException e) {
            throw new YtException("Unable to create ");
        }

        log.trace("Yt request: {} {} {}", request.getMethod(), request.getURI(), getYtHeaders(request.getAllHeaders()));
        try (CloseableHttpResponse response = httpClient.execute(request)) {
            log.debug("YT response: {} {}", response.getStatusLine().getStatusCode(),
                    getYtHeaders(response.getAllHeaders())
            );
            return command.processResponse(response);
        } catch (IOException e) {
            log.error("Exception from YT", e);
            throw new YtCommandException(command.toString(), YtStatus.UNKNOWN, null, e);
        }
    }

    private String getYtHeaders(Header[] allHeaders) {
        StringBuilder sb = new StringBuilder(32);
        for (Header header : allHeaders) {
            if (header.getName().startsWith("X-YT")
                    || HttpHeaders.CONTENT_ENCODING.equalsIgnoreCase(header.getName())
                    || HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(header.getName())
                    || HttpHeaders.ACCEPT.equalsIgnoreCase(header.getName())
                    || HttpHeaders.ACCEPT_ENCODING.equalsIgnoreCase(header.getName())) {
                sb.append(' ').append(header.getName()).append('=').append(header.getValue());
            }
        }
        return sb.toString();
    }

    File getCacheFolder(YtPath path) {
        String cluster = path.getCluster();
        return clusters.get(cluster).getCacheFolder();
    }

    String startTx(String cluster, int timeoutMs, String parentTransactionId) throws YtException {
        YtStartTxCommand command = new YtStartTxCommand(cluster, timeoutMs);
        if (parentTransactionId != null) {
            command.setParentTransactionId(parentTransactionId);
        }
        YtResult<String> result = execute(null, getCluster(cluster), command);
        throwIfError(result, command);
        return result.getResult();
    }

    void pingTx(YtTransaction transactionId) throws YtException {
        YtPingTxCommand command = new YtPingTxCommand(transactionId);
        YtResult<Void> result = execute(null, getCluster(transactionId.getCluster()), command);
        throwIfError(result, command);
    }

    YtResult<Void> pingTxInfo(YtTransaction transactionId) {
        YtPingTxCommand command = new YtPingTxCommand(transactionId);
        return execute(null, getCluster(transactionId.getCluster()), command);
    }

    void commitTx(YtTransaction transactionId) throws YtException {
        YtCommitTxCommand command = new YtCommitTxCommand(transactionId);
        YtResult<Void> result = execute(null, getCluster(transactionId.getCluster()), command);
        throwIfError(result, command);
    }

    void abortTx(YtTransaction transactionId) throws YtException {
        YtAbortTxCommand command = new YtAbortTxCommand(transactionId);
        YtResult<Void> result = execute(null, getCluster(transactionId.getCluster()), command);
        throwIfError(result, command);
    }

    String getId(YtTransaction transaction, YtPath path) throws YtException {
        YtGetAttributesCommand command = new YtGetAttributesCommand(path);
        YtResult<JsonNode> result = execute(transaction, getCluster(transaction.getCluster()), command);
        throwIfError(result, command);
        return result.getResult().get("id").asText();
    }

    void lock(YtTransaction transaction, YtPath path, YtLockMode mode) throws YtException {
        String lockObject = path.toYtPath();
        if (mode == YtLockMode.SNAPSHOT) {
            // WMC-7455 using object id instead of path
            lockObject = "#" + getId(transaction, path);
        }
        YtLockCommand command = new YtLockCommand(lockObject, mode);
        YtResult<Void> result = execute(transaction, getCluster(transaction.getCluster()), command);
        throwIfError(result, command);
        transaction.addLock(path, mode);
    }

    <T> void throwIfError(YtResult<T> result, YtCommand<T> command) throws YtException {
        if (result.isError()) {
            if (result.getException() != null) {
                log.error("Unable to execute: {} {}", command, result.getResult(), result.getException());
                throw new YtException("Unable to execute: " + command, result.getException());
            } else {
                log.error("Unable to execute: {} {}", command, result.getResult());
                throw new YtException("Unable to execute: " + command);
            }
        }
    }

    @Required
    public void setClusters(List<YtCluster> clusters) {
        this.clusters = clusters.stream().collect(Collectors.toMap(YtCluster::getName, c -> c));
    }

    @Required
    public void setDefaultCacheFolder(File defaultCacheFolder) {
        this.defaultCacheFolder = defaultCacheFolder;
    }
}
