package com.avg.zaap.crashlibrary.publish.packer;


import com.avg.zaap.crashlibrary.core.logger.ILogger;

import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;

import static java.lang.Math.floor;

/**
 * Expected structure of loaded file is
 * ___________________________
 * |filename0                |
 * |fileSizeAsLongOn8Bytes0  |
 * |streamOfBytes            |
 * |_________________________|
 * <p/>
 * Created by marek.osvald on 7.4.2016.
 */
public class BinUnPacker implements IBinUnPacker {

    private static final String TAG = BinUnPacker.class.getSimpleName();

    private static final String EXT_RESULT = ".bp";
    @SuppressWarnings("SpellCheckingInspection")
    private static final String EXT_TEMP = ".tempext";
    private static final Charset UTF_8 = Charset.forName("UTF-8");
    private static final String VISUAL_LINE_LOG_SEPARATOR = "___________________________";

    private static final byte zeroByte = 0;
    private static final int READ_WRITE_FILE_BUFFER_SIZE = 1024;
    private static final int FILE_SIZE_BUFFER_SIZE = 8;
    private static final int FILE_NAME_BUFFER_SIZE = 32;
    private static final String UNIX_FILE_SEPARATOR = "/";
    private static final String WINDOWS_FILE_SEPARATOR = "\\";
    private final IBinUnPackerFactory mFactory;
    private ILogger mLogger;

    private String mDestinationPath;
    private String mRootFolder;

    /**
     * This constructor don't allow unpacking via {@link #unpack(String, boolean, boolean)} before call {@link #setDestinationPath(String)}.
     */
    public BinUnPacker() {
        this(new BinUnPackerFactory());
    }

    /**
     * Constructor with factory.
     *
     * @param factory factory.
     */
    public BinUnPacker(IBinUnPackerFactory factory) {
        mFactory = factory;
    }

    /**
     * Constructor. After this one can be called {@link #unpack(String, boolean, boolean)}.
     *
     * @param destinationPath where the bin file will be stored.
     * @throws BinUnPackerException when construction failed.
     */
    public BinUnPacker(String destinationPath) throws BinUnPackerException {
        this(destinationPath, new BinUnPackerFactory());
    }

    /**
     * Second constructor with factory.
     *
     * @param destinationPath where the bin file will be stored.
     * @param factory         factory.
     * @throws BinUnPackerException when construction failed.
     */
    public BinUnPacker(String destinationPath, IBinUnPackerFactory factory) throws BinUnPackerException {
        mFactory = factory;
        setDestinationPath(destinationPath);
    }

    @Override
    public void setDestinationPath(String destinationPath) throws BinUnPackerException {
        mLogger = (mLogger == null) ? mFactory.getLogger() : mLogger;
        mDestinationPath = destinationPath;
        try {
            createDir(mDestinationPath);
        } catch (IOException e) {
            throw new BinUnPackerException("Destination cannot be created.", e);
        }
    }

    @Override
    public void setLogger(ILogger logger) {
        mLogger = logger;
    }

    @Override
    public void unpack(String filePath, boolean flatMode, boolean forceMode) throws BinUnPackerException {
        mLogger = (mLogger == null) ? mFactory.getLogger() : mLogger;
        RandomAccessFile accessFile = null;
        BinUnPackerException exception = null;
        try {
            accessFile = mFactory.getReadRandomAccessFile(filePath);
            String fileName = decodeFileName(accessFile);
            mLogger.d(TAG, String.format("Root file/folder name is: %s", fileName));

            // single file
            if (fileName.contains(".") && fileName.length() > 1) {
                long size = decodeFileSize(accessFile);
                mLogger.d(TAG, String.format("Size of file %s is: %s", fileName, size));

                saveDataToFile(accessFile, mDestinationPath + removeExtension(fileName), size, false);
            } else { // folder
                createDir(mDestinationPath + fileName);
                mRootFolder = mDestinationPath + fileName;

                processFolder(accessFile, flatMode, forceMode, false);
            }
        } catch (IOException e) {
            exception = new BinUnPackerException(e);
        } catch (BinUnPackerException e) {
            exception = e;
        } finally {
            handleResources(exception, accessFile);
        }
    }

    @Override
    public void list(String filePath) throws BinUnPackerException {
        mLogger = (mLogger == null) ? mFactory.getLogger() : mLogger;
        mLogger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);
        mLogger.d(TAG, String.format("Listing file: %s", filePath));

        RandomAccessFile accessFile = null;
        BinUnPackerException exception = null;
        try {
            accessFile = mFactory.getReadRandomAccessFile(filePath);
            String fileName = decodeFileName(accessFile);
            if (fileName.contains(".") && fileName.length() > 1) {
                mLogger.d(TAG, fileName);
            } else {
                mRootFolder = fileName;
                processFolder(accessFile, false, false, true);
            }
        } catch (IOException e) {
            exception = new BinUnPackerException(e);
        } finally {
            handleResources(exception, accessFile);
        }
    }

    /**
     * Main method to extract folder data.
     *
     * @param accessFile to be extracted.
     * @param flatMode   if it will be in one root folder or in original file tree structure.
     * @param forceMode  decides if new files will override existing ones, or rename new ones.
     * @param printOnly  decides if data will be stored all only printed by name.
     * @throws IOException|BinUnPackerException when some problem with files occurs.
     */
    private void processFolder(RandomAccessFile accessFile, boolean flatMode, boolean forceMode, boolean printOnly) throws IOException, BinUnPackerException {
        String relativeFilePath = File.separatorChar + decodeFileName(accessFile);
        // change folder separators if it is on windows, android and so on.
        relativeFilePath = relativeFilePath.replace(UNIX_FILE_SEPARATOR, File.separator);
        relativeFilePath = relativeFilePath.replace(WINDOWS_FILE_SEPARATOR, File.separator);
        String fileName = getNameFromPath(relativeFilePath);
        // flat mode does not need relative path to original root
        if (flatMode && relativeFilePath.contains(File.separator)) {
            relativeFilePath = relativeFilePath.substring(relativeFilePath.lastIndexOf(File.separatorChar));
        }
        // make it absolute
        String finalPath = mRootFolder + relativeFilePath;
        // size
        long size = decodeFileSize(accessFile);
        if (!printOnly) {
            createDir(finalPath.replace(fileName, ""));
            mLogger.d(TAG, String.format("Final path to store actual file is: %s", finalPath));
            mLogger.d(TAG, String.format("Size of file %s is: %s", fileName, size));
            saveDataToFile(accessFile, finalPath, size, forceMode);
        } else {
            mLogger.d(TAG, finalPath);
            skip(accessFile, size);
        }
        // check if there is some more data, if so, then extract them
        if (!isEof(accessFile)) {
            processFolder(accessFile, flatMode, forceMode, printOnly);
        }
    }

    /**
     * Skip body of file -> move pointer in input file.
     *
     * @param accessFile to move pointer.
     * @param size       to skip.
     * @throws BinUnPackerException when skip cannot be done.
     */
    private void skip(RandomAccessFile accessFile, long size) throws BinUnPackerException {
        try {
            accessFile.seek(accessFile.getFilePointer() + size);
        } catch (IOException e) {
            throw new BinUnPackerException(e);
        }
    }

    /**
     * Save data from passed file to newly created one.
     *
     * @param inputFile to read from (src).
     * @param filePath  to write to (dst).
     * @param size      of src to read, and dst to write.
     * @param forceMode if true is set, then override file if exists, otherwise rename new one created.
     * @throws BinUnPackerException when saving failed.
     */
    private void saveDataToFile(RandomAccessFile inputFile, String filePath, long size, boolean forceMode) throws BinUnPackerException {
        FileOutputStream fos = null;
        BinUnPackerException exception = null;
        try {
            fos = mFactory.getFileOutputStream(checkFileConflict(filePath, forceMode));
            byte[] buffer = new byte[READ_WRITE_FILE_BUFFER_SIZE];
            // number of read
            int count = (int) floor(size / READ_WRITE_FILE_BUFFER_SIZE);
            int supplement = (int) (size % READ_WRITE_FILE_BUFFER_SIZE);
            int i = count + ((supplement > 0) ? 1 : 0);

            while (i-- > 0) {
                if (count-- > 0) {
                    inputFile.read(buffer);
                    fos.write(buffer);
                } else {
                    inputFile.read(buffer, 0, supplement);
                    fos.write(buffer, 0, supplement);
                }
            }
        } catch (IOException e) {
            exception = new BinUnPackerException(e);
        } finally {
            handleResources(exception, fos);
        }
    }

    /**
     * Check if file exists, if so, try to increment filename to 0_filename, 1_filename and so on.
     *
     * @param filePath  to check.
     * @param forceMode if is set to true, files is overwritten, no renaming.
     * @return final file.
     */
    private File checkFileConflict(String filePath, boolean forceMode) {
        File file = mFactory.getFile(filePath);
        if (!forceMode) {
            String result = filePath;
            int counter = 0;
            while (file.exists()) {
                mLogger.d(TAG, String.format("File %s already exists.", getNameFromPath(file.getAbsolutePath())));
                result = String.format("%s%s%s_%s", getParentPath(filePath), File.separatorChar, counter, getNameFromPath(filePath));
                file = mFactory.getFile(result);
                counter++;
            }
            mLogger.d(TAG, String.format("File will be stored as: %s", result));
        }
        return file;
    }

    /**
     * Create dir if needed.
     *
     * @param destinationPath to new/old dir
     * @throws IOException when dir cannot be created.
     */
    private void createDir(String destinationPath) throws IOException {
        mLogger.d(TAG, String.format("Creating dir: %s", destinationPath));
        File dir = mFactory.getFile(destinationPath);
        if (!dir.exists()) {
            if (!dir.mkdirs()) {
                throw new IOException("Dirs cannot be constructed");
            }
        } else {
            mLogger.d(TAG, String.format("Directory %s <= already exists.", destinationPath));
        }
    }

    /**
     * Remove temporary extension of file ({@link BinUnPacker#EXT_TEMP})
     *
     * @param name of file to be process.
     */
    private String removeExtension(String name) {
        return name.substring(0, (name.endsWith(EXT_TEMP)) ? name.lastIndexOf(".") : name.length());
    }

    /**
     * Check whether the end of file occurs or not.
     *
     * @param file to check.
     * @return true if it is EOF, false otherwise.
     * @throws IOException when some problem occurs.
     */
    private boolean isEof(RandomAccessFile file) throws IOException {
        if (file.read(new byte[1]) > 0) {
            file.seek(file.getFilePointer() - 1);
            return false;
        }
        mLogger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);
        mLogger.d(TAG, "This is EOF");
        return true;
    }

    /**
     * Returns long value from passed file, move pointer in file correctly.
     * Long on 8 bytes + 1 null byte
     *
     * @param file to read from.
     * @throws IOException          when some problem occurs.
     * @throws BinUnPackerException when some problem occurs.
     */
    private long decodeFileSize(RandomAccessFile file) throws IOException, BinUnPackerException {
        byte[] buffer = new byte[FILE_SIZE_BUFFER_SIZE];
        if (file.read(buffer) > 0) {
            ByteBuffer result = ByteBuffer.allocate(Long.SIZE).put(buffer);
            result.flip();
            return result.getLong();
        } else {
            throw new BinUnPackerException("Read file size failed");
        }
    }

    /**
     * Returns stored file name, move pointer in file correctly.
     * filename + 1 null byte.
     *
     * @param file to ready from
     * @throws IOException when some problem occurs.
     */
    private String decodeFileName(RandomAccessFile file) throws IOException {
        byte[] buffer = new byte[FILE_NAME_BUFFER_SIZE];
        StringBuilder result = new StringBuilder();
        boolean found = false;
        long position = file.getFilePointer();

        // read until you can
        while (file.read(buffer) > 0) {
            for (int i = 0; i < buffer.length; i++) {
                // end point of file name
                if (buffer[i] == zeroByte) {
                    result.append(new String(Arrays.copyOf(buffer, i), UTF_8));
                    found = true;
                    position += i;
                    break;
                }
            }
            // does not contain
            if (!found) {
                result.append(new String(buffer, UTF_8));
                position += buffer.length;
            } else {
                break;
            }
        }
        // rewind to start of size
        file.seek(position + 1);
        return result.toString();
    }

    /**
     * Returns last segment of path consists of file name.
     *
     * @param path to by process.
     */
    private String getNameFromPath(String path) {
        return path.substring(path.lastIndexOf(File.separatorChar) + 1);
    }

    /**
     * Returns path to parent directory.
     *
     * @param path of file/dir in wanted dir
     */
    private String getParentPath(String path) {
        return path.substring(0, path.lastIndexOf(File.separatorChar));
    }

    /**
     * Handle try-with-resources problem.
     * Close all given resources if can.
     *
     * @param exception is thrown in the end of execution if is not null.
     * @param resources array of arguments to be closed.
     * @throws BinUnPackerException if {@code exception} is not null or resource cannot be closed.
     */
    private void handleResources(BinUnPackerException exception, Closeable... resources) throws BinUnPackerException {
        ArrayList<Exception> exceptions = new ArrayList<>();
        for (Closeable resource : resources) {
            try {
                if (resource != null) {
                    resource.close();
                }
            } catch (IOException e) {
                exceptions.add(e);
            }
        }
        if (exception != null || exceptions.size() > 0) {
            if (exception != null) {
                exceptions.add(0, exception);
            }
            throw new IBinUnPacker.BinUnPackerException(mergeExceptions(exceptions), exceptions.get(0));
        }
    }

    /**
     * Combine messages from given exceptions into one according their position in list.
     *
     * @param exceptions to be merged.
     * @return string consists of line {ExceptionMessage1\nExceptionMessage2\n.....}
     */
    private
    String mergeExceptions(ArrayList<Exception> exceptions) {
        StringBuilder msgBuilder = new StringBuilder();
        for (Exception exception : exceptions) {
            if (exception != null) {
                msgBuilder.append(exception.getMessage());
                msgBuilder.append('\n');
            }
        }
        return msgBuilder.toString();
    }

    /************************************************************************************************************************************************/

    /**
     * Main method to call from command line.
     *
     * @param args: none, two, three or four
     *              none:    read input.bp
     *              -> {@link BinUnPacker#EXT_RESULT} from current dir and store there output.
     *              two:     source_file.bp destination_dir
     *              or
     *              source_file.bp --list
     *              -> print files stored in given file
     *              three:   source_file.bp destination_dir --flat
     *              source_file.bp destination_dir --force
     *              four:    source_file.bp destination_dir --force --flat
     *              source_file.bp destination_dir --flat --force
     *              NOTE: common way is extract non_flatly, non_forced. For more about flat and/or force mode see {@link BinUnPacker#unpack(String, boolean, boolean)}.
     * @throws IOException when some problem occurs.
     */
    public static void main(String[] args) throws IOException {
        // set custom logger for standalone usage
        ILogger logger = new BinUnPackerFactory().getLogger();

        logger.d(TAG, String.format("Args count: %s", args.length));
        logger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);

        final String FLAT_FLAG = "--flat";
        final String FORCE_FLAG = "--force";
        final String LIST_FLAG = "--list";

        String source;
        String destination;
        boolean flatMode = false;
        boolean forceMode = false;
        boolean list = false;

        switch (args.length) {
            case 0:
                help(logger, 0);
                String current = new File(".").getCanonicalPath();
                logger.d(TAG, String.format("Current dir: %s", current));

                destination = current + File.separatorChar;
                source = current + File.separatorChar + "input" + EXT_RESULT;
                break;
            case 2:
                source = args[0];
                destination = args[1];
                list = LIST_FLAG.equals(args[1]);
                break;
            case 3:
                source = args[0];
                destination = args[1];
                flatMode = FLAT_FLAG.equals(args[2]);
                forceMode = FORCE_FLAG.equals(args[2]);
                break;
            case 4:
                source = args[0];
                destination = args[1];
                flatMode = FLAT_FLAG.equals(args[2]) || FLAT_FLAG.equals(args[3]);
                forceMode = FORCE_FLAG.equals(args[2]) || FORCE_FLAG.equals(args[3]);
                break;
            default:
                help(logger, 1);
                return;
        }
        try {
            if (!list) {
                new BinUnPacker(destination).unpack(source, flatMode, forceMode);
            } else {
                new BinUnPacker().list(source);
            }
        } catch (BinUnPackerException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Simple help.
     *
     * @param logger to print.
     * @param count  what type of help.
     */
    private static void help(ILogger logger, int count) {
        if (count == 0) {
            logger.d(TAG, "For more customizing call this tool with 1-4 arguments");
            logger.d(TAG, "Going to grab file input.bp and store it to current folder");
        } else {
            logger.d(TAG, "Wrong argument combinations");
            logger.d(TAG, "none:    read input.bp");
            logger.d(TAG, "            -> read from current dir and store there output.");
            logger.d(TAG, "two:     source_file.bp destination_dir");
            logger.d(TAG, "         source_file.bp --list");
            logger.d(TAG, "            -> print files stored in given file");
            logger.d(TAG, "three:   source_file.bp destination_dir --flat");
            logger.d(TAG, "         source_file.bp destination_dir --force");
            logger.d(TAG, "four:    source_file.bp destination_dir --force --flat");
            logger.d(TAG, "         source_file.bp destination_dir --flat --force");

        }
        logger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);
    }
}
