/*
 * Copyright 2015 Kakao Corporation
 *
 * Licensed 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.kakao.hbase.manager.command;

import com.kakao.hbase.common.Args;
import com.kakao.hbase.common.Constant;
import com.kakao.hbase.common.EmptyRegionChecker;
import com.kakao.hbase.common.util.Util;
import com.kakao.hbase.specific.CommandAdapter;
import com.kakao.hbase.specific.RegionLoadDelegator;
import com.kakao.hbase.stat.load.TableInfo;
import org.apache.hadoop.hbase.HRegionInfo;
import org.apache.hadoop.hbase.RegionException;
import org.apache.hadoop.hbase.client.HBaseAdmin;
import org.apache.hadoop.hbase.client.HConnection;
import org.apache.hadoop.hbase.client.HConnectionManager;
import org.apache.hadoop.hbase.util.Bytes;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Merge implements Command {
    private final HBaseAdmin admin;
    private final Args args;
    private final String actionParam;
    private final Set<String> tableNameSet;
    private final HConnection connection;
    private boolean proceed = false;
    private boolean test = false;
    private boolean isPhoenixSaltingTable;

    public Merge(HBaseAdmin admin, Args args) throws IOException {
        if (args.getOptionSet().nonOptionArguments().size() < 2
                || args.getOptionSet().nonOptionArguments().size() > 3) { // todo refactoring
            throw new RuntimeException(Args.INVALID_ARGUMENTS);
        }

        this.admin = admin;
        this.args = args;
        if (args.has(Args.OPTION_TEST)) this.test = true;
        actionParam = (String) args.getOptionSet().nonOptionArguments().get(2);
        this.connection = HConnectionManager.createConnection(admin.getConfiguration());

        tableNameSet = Util.parseTableSet(admin, args);

        if (args.has(Args.OPTION_PHOENIX)) this.isPhoenixSaltingTable = true;
    }

    @SuppressWarnings("unused")
    public static String usage() {
        return "Merge regions. It may take a long time.\n"
                + "usage: "
                + Merge.class.getSimpleName().toLowerCase()
                + " <zookeeper quorum> <table name(regex)> <action> [options]\n"
                + "  actions:\n"
                + "    empty-fast       - Merge adjacent 2 empty regions only.\n"
                + "    empty            - Merge all empty regions.\n"
                + "  options:\n"
                + "    --" + Args.OPTION_MAX_ITERATION + " - Set max iteration.\n"
                + "    --" + Args.OPTION_PHOENIX + " - Set if the table to be merged is phoenix salted table.\n"
                + Args.commonUsage();
    }

    private int getMaxMaxIteration() {
        if (args.has(Args.OPTION_MAX_ITERATION)) {
            return (int) args.valueOf(Args.OPTION_MAX_ITERATION);
        } else
            return Constant.TRY_MAX;
    }

    public void setTest(boolean test) {
        this.test = test;
    }

    @Override
    public void run() throws Exception {
        long timestampPrev;
        int i = 1;
        // todo refactoring
        if (actionParam.toLowerCase().equals("empty-fast")) {
            for (String tableName : tableNameSet) {
                timestampPrev = System.currentTimeMillis();
                TableInfo tableInfo = new TableInfo(admin, tableName, args);
                Util.printMessage(i + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty-fast - Start");
                timestampPrev = Util.printVerboseMessage(args, "Merge.run.new TableInfo", timestampPrev);
                emptyFast(tableInfo);
                Util.printVerboseMessage(args, "Merge.run.emptyFast", timestampPrev);
                Util.printMessage(i++ + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty-fast - End\n");
            }
        } else if (actionParam.toLowerCase().equals("empty")) {
            for (String tableName : tableNameSet) {
                TableInfo tableInfo = new TableInfo(admin, tableName, args);

                Util.printMessage(i + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty-fast - Start");
                emptyFast(tableInfo);
                Util.printMessage(i + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty-fast - End\n");

                Util.printMessage(i + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty - Start");
                empty(tableInfo);
                Util.printMessage(i++ + "/" + tableNameSet.size() + " - Table - " + tableName + " - empty - End");
            }
        } else if (actionParam.toLowerCase().equals("size")) {
            throw new IllegalStateException("Not implemented yet"); //todo
        } else {
            throw new IllegalArgumentException("Invalid merge action - " + actionParam);
        }
    }

    private long getMergeWaitIntervalMs() {
        if (test)
            return Constant.WAIT_INTERVAL_MS;
        else
            return Constant.LARGE_WAIT_INTERVAL_MS;
    }

    private void empty(TableInfo tableInfo) throws Exception {
        for (int i = 1; i <= getMaxMaxIteration(); i++) {
            try {
                if (!args.isForceProceed()) {
                    if (!proceed)
                        proceed = Util.askProceed();
                    if (!proceed) {
                        return;
                    }
                }

                if (emptyInternal(tableInfo)) break;
                Util.printMessage("Iteration " + i + "/" + getMaxMaxIteration() + " - Wait for "
                        + getMergeWaitIntervalMs() / 1000 + " seconds\n");
                Thread.sleep(getMergeWaitIntervalMs());
            } catch (IllegalStateException e) {
                if (e.getMessage().contains(Constant.MESSAGE_NEED_REFRESH)) {
                    Thread.sleep(Constant.WAIT_INTERVAL_MS);
                } else {
                    throw e;
                }
            }
        }
    }

    /**
     * @param tableInfo
     * @return true: stop iteration, false: continue iteration
     * @throws Exception
     */
    private boolean emptyInternal(TableInfo tableInfo) throws Exception {
        tableInfo.refresh();

        Set<HRegionInfo> mergedRegions = new HashSet<>();
        List<HRegionInfo> allTableRegions = new ArrayList<>(tableInfo.getRegionInfoSet());
        for (int i = 0; i < allTableRegions.size(); i++) {
            HRegionInfo region = allTableRegions.get(i);
            if (mergedRegions.contains(region)) continue;

            RegionLoadDelegator regionLoad = tableInfo.getRegionLoad(region);
            if (regionLoad == null) throw new IllegalStateException(Constant.MESSAGE_NEED_REFRESH);

            HRegionInfo targetRegion = getTargetRegion(tableInfo, allTableRegions, i, mergedRegions);
            if (mergedRegions.contains(targetRegion)) continue;

            if (regionLoad.getStorefileSizeMB() == 0 && regionLoad.getMemStoreSizeMB() == 0) {
                if (CommandAdapter.isReallyEmptyRegion(connection, tableInfo.getTableName(), region)) {
                    try {
                        if (targetRegion != null) {
                            printMergeInfo(region, targetRegion);
                            mergedRegions.add(region);
                            mergedRegions.add(targetRegion);
                            CommandAdapter.mergeRegions(args, admin, region, targetRegion);
                            i++;
                        }
                    } catch (RegionException e) {
                        throw new IllegalStateException(Constant.MESSAGE_NEED_REFRESH);
                    }
                }
            }
        }

        System.out.println();
        return mergedRegions.size() <= 1;
    }

    private HRegionInfo getTargetRegion(TableInfo tableInfo, List<HRegionInfo> allTableRegions,
                                        int i, Set<HRegionInfo> mergedRegions) {
        HRegionInfo regionPrev = i > 0 ? allTableRegions.get(i - 1) : null;
        if (mergedRegions.contains(regionPrev)) regionPrev = null;
        HRegionInfo regionNext = i == allTableRegions.size() - 1 ? null : allTableRegions.get(i + 1);

        int sizePrev = getSize(tableInfo, regionPrev);
        int sizeNext = getSize(tableInfo, regionNext);
        if (sizePrev <= sizeNext)
            return regionPrev;
        else
            return regionNext;
    }

    private int getSize(TableInfo tableInfo, HRegionInfo region) {
        if (region != null) {
            RegionLoadDelegator regionLoad = tableInfo.getRegionLoad(region);
            if (regionLoad != null) {
                return regionLoad.getMemStoreSizeMB() + regionLoad.getStorefileSizeMB();
            }
        }

        return Integer.MAX_VALUE;
    }

    private boolean isRegionBoundaryOfPhoenixSaltingTable(HRegionInfo regionInfo) {
        byte[] endKey = regionInfo.getEndKey();
        boolean boundaryRegionForPhoenix = true;

        if (endKey.length > 0) {
            for (int i = 1, limit = endKey.length; i < limit; i++) {
                if (endKey[i] != 0) {
                    boundaryRegionForPhoenix = false;
                    break;
                }
            }
        }

        if (boundaryRegionForPhoenix) {
            Util.printVerboseMessage(args, regionInfo.getEncodedName() + " is boundary region of phoenix : "
                    + Bytes.toStringBinary(regionInfo.getStartKey()) + " ~ " + Bytes.toStringBinary(regionInfo.getEndKey()));
        }

        return boundaryRegionForPhoenix;
    }

    private void emptyFast(TableInfo tableInfo) throws Exception {
        long timestampPrev;
        boolean merged;
        for (int j = 1; j <= getMaxMaxIteration(); j++) {
            merged = false;

            timestampPrev = System.currentTimeMillis();
            List<HRegionInfo> emptyRegions = findEmptyRegions(tableInfo);
            timestampPrev = Util.printVerboseMessage(args, "Merge.emptyFast.findEmptyRegions", timestampPrev);
            if (emptyRegions.size() > 1) {
                List<HRegionInfo> adjacentEmptyRegions = CommandAdapter.adjacentEmptyRegions(emptyRegions);
                Util.printVerboseMessage(args, "Merge.emptyFast.adjacentEmptyRegions", timestampPrev);
                System.out.println();
                Util.printMessage("Iteration " + j + "/" + getMaxMaxIteration() + " - "
                        + adjacentEmptyRegions.size() + " adjacent empty regions are found");
                if (adjacentEmptyRegions.size() == 0) return;
                emptyRegions = adjacentEmptyRegions;
                if (!args.isForceProceed()) {
                    if (!proceed)
                        proceed = Util.askProceed();
                    if (!proceed) {
                        return;
                    }
                }
            } else {
                break;
            }

            for (int i = 0; i < emptyRegions.size(); i++) {
                HRegionInfo regionA = emptyRegions.get(i);
                // 첫번째 리전의 endKey가 피닉스 솔팅 테이블의 리전 바운더리가 아니여야 한다.
                if (isPhoenixSaltingTable && isRegionBoundaryOfPhoenixSaltingTable(regionA)) {
                    continue;
                }

                if (i != emptyRegions.size() - 1) {
                    HRegionInfo regionB = emptyRegions.get(i + 1);

                    boolean mergeRegions = false;
                    for (int k = 0; k < Constant.TRY_MAX_SMALL; k++) {
                        try {
                            mergeRegions = CommandAdapter.mergeRegions(args, admin, regionA, regionB);
                            break;
                        } catch (Exception e) {
                            e.printStackTrace();
                            Thread.sleep(Constant.WAIT_INTERVAL_MS);
                        }
                    }
                    if (mergeRegions) {
                        i++;
                        printMergeInfo(regionA, regionB);
                        merged = true;
                    }
                }
                if (!merged)
                    Util.printMessage("Skip merging - " + regionA.getEncodedName()
                            + " - There is no adjacent empty region");
            }

            if (merged) {
                System.out.println("Sleeping for "
                        + (getMergeWaitIntervalMs() / 1000) + " seconds.");
                Thread.sleep(getMergeWaitIntervalMs());
            } else {
                break;
            }
        }
    }

    private void printMergeInfo(HRegionInfo regionA, HRegionInfo regionB) {
        Util.printMessage("Merge regions");
        System.out.println("  - " + Util.getRegionInfoString(regionA));
        System.out.println("  └ " + Util.getRegionInfoString(regionB));
    }

    private List<HRegionInfo> findEmptyRegions(TableInfo tableInfo) throws Exception {
        for (int i = 0; i < Constant.TRY_MAX; i++) {
            try {
                return findEmptyRegionsInternal(tableInfo);
            } catch (IllegalStateException e) {
                if (e.getMessage().contains(Constant.MESSAGE_NEED_REFRESH)) {
                    Thread.sleep(Constant.WAIT_INTERVAL_MS);
                } else {
                    throw e;
                }
            }
        }

        throw new IllegalStateException("findEmptyRegions failed");
    }

    private List<HRegionInfo> findEmptyRegionsInternal(TableInfo tableInfo) throws Exception {
        long timestamp = System.currentTimeMillis();

        Set<HRegionInfo> emptyRegions =
                Collections.synchronizedSet(Collections.newSetFromMap(new TreeMap<HRegionInfo, Boolean>()));

        tableInfo.refresh();

        ExecutorService executorService = Executors.newFixedThreadPool(EmptyRegionChecker.THREAD_POOL_SIZE);
        try {
            EmptyRegionChecker.resetCounter();

            Set<HRegionInfo> allTableRegions = tableInfo.getRegionInfoSet();
            for (HRegionInfo regionInfo : allTableRegions) {
                RegionLoadDelegator regionLoad = tableInfo.getRegionLoad(regionInfo);
                if (regionLoad == null) {
                    Util.printMessage("RegionLoad is empty - " + regionInfo);
                    throw new IllegalStateException(Constant.MESSAGE_NEED_REFRESH);
                }

                if (regionLoad.getStorefileSizeMB() == 0 && regionLoad.getMemStoreSizeMB() == 0) {
                    executorService.execute(
                            new EmptyRegionChecker(connection, tableInfo.getTableName(), regionInfo, emptyRegions));
                }
            }
        } finally {
            executorService.shutdown();
            executorService.awaitTermination(30, TimeUnit.MINUTES);
        }
        Util.printMessage(emptyRegions.size() + " empty regions are found.");

        Util.printVerboseMessage(args, Util.getMethodName(), timestamp);
        return new ArrayList<>(emptyRegions);
    }
}