/*
 * Copyright 2017 HugeGraph Authors
 *
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with this
 * work for additional information regarding copyright ownership. The ASF
 * licenses this file to You under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

package com.baidu.hugegraph.manager;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;

import org.apache.commons.io.FileUtils;

import com.baidu.hugegraph.api.API;
import com.baidu.hugegraph.base.Printer;
import com.baidu.hugegraph.base.ToolClient;
import com.baidu.hugegraph.cmd.SubCommands;
import com.baidu.hugegraph.driver.TraverserManager;
import com.baidu.hugegraph.exception.ToolsException;
import com.baidu.hugegraph.structure.constant.HugeType;
import com.baidu.hugegraph.structure.graph.Edge;
import com.baidu.hugegraph.structure.graph.Edges;
import com.baidu.hugegraph.structure.graph.Shard;
import com.baidu.hugegraph.structure.graph.Vertex;
import com.baidu.hugegraph.structure.graph.Vertices;
import com.baidu.hugegraph.structure.schema.EdgeLabel;
import com.baidu.hugegraph.structure.schema.IndexLabel;
import com.baidu.hugegraph.structure.schema.PropertyKey;
import com.baidu.hugegraph.structure.schema.VertexLabel;
import com.baidu.hugegraph.util.E;

import jersey.repackaged.com.google.common.collect.ImmutableList;

public class BackupManager extends BackupRestoreBaseManager {

    private static final String SHARDS_SUFFIX = "_shards";
    private static final String ALL_SHARDS = "_all" + SHARDS_SUFFIX;
    private static final String TIMEOUT_SHARDS = "_timeout" + SHARDS_SUFFIX;
    private static final String LIMIT_EXCEED_SHARDS = "_limit_exceed" + SHARDS_SUFFIX;
    private static final String FAILED_SHARDS = "_failed" + SHARDS_SUFFIX;

    public static final int BACKUP_DEFAULT_TIMEOUT = 120;

    private static final AtomicInteger nextId = new AtomicInteger(0);
    private static final ThreadLocal<Integer> suffix =
            ThreadLocal.withInitial(nextId::getAndIncrement);

    private long splitSize;

    public BackupManager(ToolClient.ConnectionInfo info) {
        super(info, "backup");
    }

    public void init(SubCommands.Backup backup) {
        super.init(backup);
        this.removeShardsFilesIfExists();
        this.ensureDirectoryExist(true);
        long splitSize = backup.splitSize();
        E.checkArgument(splitSize >= 1024 * 1024,
                        "Split size must >= 1M, but got %s", splitSize);
        this.splitSize(splitSize);
    }

    public void splitSize(long splitSize) {
        this.splitSize = splitSize;
    }

    public long splitSize() {
        return this.splitSize;
    }

    public void backup(List<HugeType> types) {
        this.startTimer();
        for (HugeType type : types) {
            switch (type) {
                case VERTEX:
                    this.backupVertices();
                    break;
                case EDGE:
                    this.backupEdges();
                    break;
                case PROPERTY_KEY:
                    this.backupPropertyKeys();
                    break;
                case VERTEX_LABEL:
                    this.backupVertexLabels();
                    break;
                case EDGE_LABEL:
                    this.backupEdgeLabels();
                    break;
                case INDEX_LABEL:
                    this.backupIndexLabels();
                    break;
                default:
                    throw new AssertionError(String.format(
                              "Bad backup type: %s", type));
            }
        }
        this.shutdown(this.type());
        this.printSummary();
    }

    protected void backupVertices() {
        Printer.print("Vertices backup started");
        Printer.printInBackward("Vertices has been backup: ");
        List<Shard> shards = retry(() ->
                             this.client.traverser().vertexShards(splitSize()),
                             "querying shards of vertices");
        this.writeShards(this.allShardsLog(HugeType.VERTEX), shards);
        for (Shard shard : shards) {
            this.backupVertexShardAsync(shard);
        }
        this.awaitTasks();
        this.postProcessFailedShard(HugeType.VERTEX);
        Printer.print("%d", this.vertexCounter.get());
        Printer.print("Vertices backup finished: %d",
                      this.vertexCounter.get());
    }

    protected void backupEdges() {
        Printer.print("Edges backup started");
        Printer.printInBackward("Edges has been backup: ");
        List<Shard> shards = retry(() ->
                             this.client.traverser().edgeShards(splitSize()),
                             "querying shards of edges");
        this.writeShards(this.allShardsLog(HugeType.EDGE), shards);
        for (Shard shard : shards) {
            this.backupEdgeShardAsync(shard);
        }
        this.awaitTasks();
        this.postProcessFailedShard(HugeType.EDGE);
        Printer.print("%d", this.edgeCounter.get());
        Printer.print("Edges backup finished: %d", this.edgeCounter.get());
    }

    protected void backupPropertyKeys() {
        Printer.print("Property key backup started");
        List<PropertyKey> pks = this.client.schema().getPropertyKeys();
        this.propertyKeyCounter.getAndAdd(pks.size());
        this.backup(HugeType.PROPERTY_KEY, pks);
        Printer.print("Property key backup finished: %d",
                      this.propertyKeyCounter.get());
    }

    protected void backupVertexLabels() {
        Printer.print("Vertex label backup started");
        List<VertexLabel> vls = this.client.schema().getVertexLabels();
        this.vertexLabelCounter.getAndAdd(vls.size());
        this.backup(HugeType.VERTEX_LABEL, vls);
        Printer.print("Vertex label backup finished: %d",
                      this.vertexLabelCounter.get());
    }

    protected void backupEdgeLabels() {
        Printer.print("Edge label backup started");
        List<EdgeLabel> els = this.client.schema().getEdgeLabels();
        this.edgeLabelCounter.getAndAdd(els.size());
        this.backup(HugeType.EDGE_LABEL, els);
        Printer.print("Edge label backup finished: %d",
                      this.edgeLabelCounter.get());
    }

    protected void backupIndexLabels() {
        Printer.print("Index label backup started");
        List<IndexLabel> ils = this.client.schema().getIndexLabels();
        this.indexLabelCounter.getAndAdd(ils.size());
        this.backup(HugeType.INDEX_LABEL, ils);
        Printer.print("Index label backup finished: %d",
                      this.indexLabelCounter.get());
    }

    private void backupVertexShardAsync(Shard shard) {
        this.submit(() -> {
            try {
                backupVertexShard(shard);
            } catch (Throwable e) {
                this.logExceptionWithShard(e, HugeType.VERTEX, shard);
            }
        });
    }

    private void backupEdgeShardAsync(Shard shard) {
        this.submit(() -> {
            try {
                backupEdgeShard(shard);
            } catch (Throwable e) {
                this.logExceptionWithShard(e, HugeType.EDGE, shard);
            }
        });
    }

    private void backupVertexShard(Shard shard) {
        String desc = String.format("backing up vertices[shard:%s]", shard);
        Vertices vertices = null;
        String page = "";
        TraverserManager g = client.traverser();
        do {
            String finalPage = page;
            try {
                vertices = retry(() -> g.vertices(shard, finalPage), desc);
            } catch (ToolsException e) {
                this.exceptionHandler(e, HugeType.VERTEX, shard);
            }
            if (vertices == null) {
                return;
            }
            List<Vertex> vertexList = vertices.results();
            if (vertexList == null || vertexList.isEmpty()) {
                return;
            }
            this.backup(HugeType.VERTEX, suffix.get(), vertexList);

            this.vertexCounter.getAndAdd(vertexList.size());
            Printer.printInBackward(this.vertexCounter.get());
        } while ((page = vertices.page()) != null);
    }

    private void backupEdgeShard(Shard shard) {
        String desc = String.format("backing up edges[shard %s]", shard);
        Edges edges = null;
        List<Edge> edgeList;
        String page = "";
        do {
            try {
                String p = page;
                edges = retry(() -> client.traverser().edges(shard, p), desc);
            } catch (ToolsException e) {
                this.exceptionHandler(e, HugeType.EDGE, shard);
            }
            if (edges == null) {
                return;
            }
            edgeList = edges.results();
            if (edgeList == null || edgeList.isEmpty()) {
                return;
            }
            this.backup(HugeType.EDGE, suffix.get(), edgeList);

            this.edgeCounter.getAndAdd(edgeList.size());
            Printer.printInBackward(this.edgeCounter.get());
        } while ((page = edges.page()) != null);
    }

    private void backup(HugeType type, List<?> list) {
        String file = type.string();
        this.write(file, type, list);
    }

    private void backup(HugeType type, int number, List<?> list) {
        String file = type.string() + number;
        int size = list.size();
        for (int start = 0; start < size; start += BATCH) {
            int end = Math.min(start + BATCH, size);
            this.write(file, type, list.subList(start, end));
        }
    }

    private void exceptionHandler(ToolsException e, HugeType type,
                                  Shard shard) {
        String message = e.getMessage();
        switch (type) {
            case VERTEX:
                E.checkState(message.contains("backing up vertices"),
                             "Unexpected exception %s", e);
                break;
            case EDGE:
                E.checkState(message.contains("backing up edges"),
                             "Unexpected exception %s", e);
                break;
            default:
                throw new AssertionError(String.format(
                          "Only VERTEX or EDGE exception is expected, " +
                          "but got '%s' exception", type));
        }
        if (isLimitExceedException(e)) {
            this.logLimitExceedShard(type, shard);
        } else if (isTimeoutException(e)) {
            this.logTimeoutShard(type, shard);
        } else {
            this.logExceptionWithShard(e, type, shard);
        }
    }

    private void logTimeoutShard(HugeType type, Shard shard) {
        String file = type.string() + TIMEOUT_SHARDS;
        this.writeShard(Paths.get(this.logDir(), file).toString(), shard);
    }

    private void logLimitExceedShard(HugeType type, Shard shard) {
        String file = type.string() + LIMIT_EXCEED_SHARDS;
        this.writeShard(Paths.get(this.logDir(), file).toString(), shard);
    }

    private void logExceptionWithShard(Object e, HugeType type, Shard shard) {
        String fileName = type.string() + FAILED_SHARDS;
        String filePath = Paths.get(this.logDir(), fileName).toString();
        try (FileWriter writer = new FileWriter(filePath, true)) {
            writer.write(shard.toString() + "\n");
            writer.write(exceptionStackTrace(e) + "\n");
        } catch (IOException e1) {
            Printer.print("Failed to write shard '%s' with exception '%s'",
                          shard, e);
        }
    }

    private void postProcessFailedShard(HugeType type) {
        this.processTimeoutShards(type);
        this.processLimitExceedShards(type);
    }

    private void processTimeoutShards(HugeType type) {
        Path path = Paths.get(this.logDir(), type.string() + TIMEOUT_SHARDS);
        File shardFile = path.toFile();
        if (!shardFile.exists() || shardFile.isDirectory()) {
            return;
        }
        Printer.print("Timeout occurs when backup %s shards in file '%s', " +
                      "try to use global option --timeout to increase " +
                      "connection timeout(default is 120s for backup) or use " +
                      "option --split-size to decrease split size",
                      type, shardFile);
    }

    private void processLimitExceedShards(HugeType type) {
        Path path = Paths.get(this.logDir(),
                              type.string() + LIMIT_EXCEED_SHARDS);
        File shardFile = path.toFile();
        if (!shardFile.exists() || shardFile.isDirectory()) {
            return;
        }
        Printer.print("Limit exceed occurs when backup %s shards in file '%s'",
                      type, shardFile);
    }

    private List<Shard> readShards(File file) {
        E.checkArgument(file.exists() && file.isFile() && file.canRead(),
                        "Need to specify a readable filter file rather than:" +
                        " %s", file.toString());
        List<Shard> shards = new ArrayList<>();
        try (InputStream is = new FileInputStream(file);
             InputStreamReader isr = new InputStreamReader(is, API.CHARSET);
             BufferedReader reader = new BufferedReader(isr)) {
            String line;
            while ((line = reader.readLine()) != null) {
                shards.addAll(this.readList("shards", Shard.class, line));
            }
        } catch (IOException e) {
            throw new ToolsException("IOException occur while reading %s",
                                     e, file.getName());
        }
        return shards;
    }

    private void writeShard(String file, Shard shard) {
        this.writeShards(file, ImmutableList.of(shard));
    }

    private void writeShards(String file, List<Shard> shards) {
        this.writeLog(file, "shards", shards);
    }

    private void writeLog(String file, String type, List<?> list) {
        Lock lock = locks.lock(file);
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream(LBUF_SIZE);
             FileOutputStream fos = new FileOutputStream(file, false)) {
            String key = String.format("{\"%s\": ", type);
            baos.write(key.getBytes(API.CHARSET));
            this.client.mapper().writeValue(baos, list);
            baos.write("}\n".getBytes(API.CHARSET));
            fos.write(baos.toByteArray());
        } catch (Exception e) {
            Printer.print("Failed to serialize %s: %s", type, e);
        } finally {
            lock.unlock();
        }
    }

    private String allShardsLog(HugeType type) {
        String shardsFile = type.string() + ALL_SHARDS;
        return Paths.get(this.logDir(), shardsFile).toString();
    }

    private void removeShardsFilesIfExists() {
        File logDir = new File(this.logDir());
        E.checkArgument(logDir.exists() && logDir.isDirectory(),
                        "The log directory '%s' not exists or is file",
                        logDir);
        for (File file : logDir.listFiles()) {
            if (file.getName().endsWith(SHARDS_SUFFIX)) {
                try {
                    FileUtils.forceDelete(file);
                } catch (IOException e) {
                    throw new ToolsException("Failed to delete shard file " +
                                             "'%s'", file);
                }
            }
        }
    }

    private static boolean isTimeoutException(ToolsException e) {
        return e.getCause() != null && e.getCause().getCause() != null &&
               e.getCause().getCause().getMessage().contains("Read timed out");
    }

    private static boolean isLimitExceedException(ToolsException e) {
        return e.getCause() != null &&
               e.getCause().getMessage().contains("Too many records");
    }

    private static String exceptionStackTrace(Object e) {
        if (!(e instanceof Throwable)) {
            return e.toString();
        }
        Throwable t = (Throwable) e;
        StringBuilder sb = new StringBuilder();
        sb.append(t.getMessage()).append("\n");
        if (t.getCause() != null) {
            sb.append(t.getCause().toString()).append("\n");
        }
        for (StackTraceElement element : t.getStackTrace()) {
            sb.append(element).append("\n");
        }
        return sb.toString();
    }
}