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

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

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

import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;

/**
 * Class for packing file to .bp file with specific structure for file.
 * ___________________________
 * |filename0                |
 * |fileSizeAsLongOn8Bytes   |
 * |streamOfBytes            |
 * |_________________________|
 * for folder of list of files:
 * ___________________________
 * |folderName0              |
 * |filename0                |
 * |fileSizeAsLongOn8Bytes   |
 * |streamOfBytes            |
 * |_________________________|
 * <p/>
 * Created by marek.osvald on 7.4.2016.
 */
public class BinPacker implements IBinPacker {

    private static final String TAG = BinPacker.class.getSimpleName();
    /**
     * Type of encoding for text.
     */
    private static final String UTF8 = "UTF-8";
    /**
     * Custom temporary file extension for files without extension (to recognize from folder).
     */
    @SuppressWarnings("SpellCheckingInspection")
    private static final String EXT_TEMP = ".tempext";
    /**
     * Default destination when none is defined.
     */
    private static final String DEFAULT_OUTPUT = "output";
    /**
     * Visual separator for more readable logs. :]
     */
    private static final String VISUAL_LINE_LOG_SEPARATOR = "___________________________";
    /**
     * Zero byte for filename/folderName stop char.
     */
    private static final byte zeroByte = 0;
    private static final int READ_WRITE_FILE_BUFFER_SIZE = 8192;

    private final IBinPackerFactory mFactory;
    private File mDestinationDir;
    private boolean mDirRemoved;

    /**
     * Constructor.
     *
     * @param destinationPath where the bin file will be stored.
     * @throws BinPackerException when construction failed.
     */
    public BinPacker(@NonNull String destinationPath) throws BinPackerException {
        this(destinationPath, new BinPackerFactory());
    }

    /**
     * Constructor with factory.
     *
     * @param destinationPath where the bin file will be stored.
     * @param factory         factory.
     * @throws BinPackerException when construction failed.
     */
    public BinPacker(@NonNull String destinationPath, IBinPackerFactory factory) throws BinPackerException {
        mFactory = factory;
        mDestinationDir = mFactory.getFile(destinationPath);
        if (!mDestinationDir.exists() && !mDestinationDir.mkdirs()) {
            throw new BinPackerException("Dirs could not been created");
        }
    }

    @Override
    public String packFile(@NonNull final String filePath, boolean forceMode) throws BinPackerException {
        // throw exception when does not fits
        checkCorrectness(filePath, false);
        CrashLogger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);

        String fileName = getNameFromPath(filePath);
        final String fileWithoutExtension = removeExtension(fileName);
        final String outputFilePath = mDestinationDir.getAbsolutePath() + File.separatorChar + fileWithoutExtension + EXT_RESULT;

        FileOutputStream fos = null;
        FileInputStream fin = null;
        BinPackerException exception = null;
        try {
            fos = mFactory.getFileOutputStream(checkFileConflict(outputFilePath, forceMode));
            fin = mFactory.getFileInputStream(filePath);
            // write file name
            fileName = (mDirRemoved) ? fileName : fileWithoutExtension + EXT_TEMP;
            fos.write(fileName.getBytes(UTF8));
            CrashLogger.d(TAG, String.format("Going to pack file with name: %s", fileName));
            // zero
            fos.write(zeroByte);
            // size
            File file = mFactory.getFile(filePath);
            long size = file.length();
            CrashLogger.d(TAG, String.format("Size of file %s is: %s", fileName, size));
            fos.write(convertSize(size));
            // body of file
            byte[] buffer = new byte[READ_WRITE_FILE_BUFFER_SIZE];
            int count;
            while ((count = fin.read(buffer)) > 0) {
                fos.write(buffer, 0, count);
            }
        } catch (IOException e) {
            exception = new BinPackerException(e);
        } finally {
            handleResources(exception, fos, fin);
        }
        return outputFilePath;
    }

    @Override
    public String packFolder(@NonNull final String folderPath, boolean forceMode) throws BinPackerException {
        checkCorrectness(folderPath, true);
        String folderName = getNameFromPath(folderPath);
        String outputFilePath = mDestinationDir.getAbsolutePath() + File.separatorChar + folderName + EXT_RESULT;
        CrashLogger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);
        CrashLogger.d(TAG, String.format("Going to pack folder with name: %s", folderName));

        FileOutputStream fos = null;
        BinPackerException exception = null;
        try {
            fos = mFactory.getFileOutputStream(checkFileConflict(outputFilePath, forceMode));
            File root = mFactory.getFile(folderPath);
            // write root folder name
            fos.write(folderName.getBytes(UTF8));
            // zero
            fos.write(zeroByte);
            // walk through folder
            walkWrapper(fos, root, folderPath);
        } catch (IOException e) {
            exception = new BinPackerException(e);
        } catch (BinPackerException e) {
            exception = e;
        } finally {
            handleResources(exception, fos);
        }
        return outputFilePath;
    }

    @Override
    public String packAll(String outputFolderName, @NonNull ArrayList<String> paths, boolean forceMode) throws BinPackerException {
        CrashLogger.d(TAG, VISUAL_LINE_LOG_SEPARATOR);
        outputFolderName = (outputFolderName != null) ? outputFolderName : DEFAULT_OUTPUT;
        if (paths.isEmpty()) {
            throw new BinPackerException("There is nothing to pack.");
        }
        String outputFilePath = mDestinationDir.getAbsolutePath() + File.separatorChar + outputFolderName + EXT_RESULT;

        FileOutputStream fos = null;
        BinPackerException exception = null;
        try {
            File outputFile = checkFileConflict(outputFilePath, forceMode);
            fos = mFactory.getFileOutputStream(outputFile);
            CrashLogger.d(TAG, String.format("Going to pack list with final name: %s", outputFile.getAbsolutePath()));
            // write root folder name
            fos.write(outputFolderName.getBytes(UTF8));
            // zero
            fos.write(zeroByte);

            for (final String path : paths) {
                // check if dir or file
                File rootFile = mFactory.getFile(path);
                if (rootFile.isFile()) {
                    // write file name
                    fos.write(getNameFromPath(path).getBytes(UTF8));
                    // zero
                    fos.write(zeroByte);
                    // size
                    fos.write(convertSize(rootFile.length()));
                    // body of file
                    byte[] buffer = new byte[READ_WRITE_FILE_BUFFER_SIZE];
                    int count;
                    FileInputStream fin = null;
                    BinPackerException innerException = null;
                    try {
                        fin = mFactory.getFileInputStream(path);
                        while ((count = fin.read(buffer)) > 0) {
                            fos.write(buffer, 0, count);
                        }
                    } catch (IOException e) {
                        innerException = new BinPackerException(e);
                    } finally {
                        handleResources(innerException, fin);
                    }
                } else { // directory
                    walkWrapper(fos, rootFile, getParentPath(path));
                }
            }
        } catch (IOException e) {
            exception = new BinPackerException(e);
        } catch (BinPackerException e) {
            exception = e;
        } finally {
            handleResources(exception, fos);
        }

        CrashLogger.d(TAG, "Returning: " + outputFilePath);
        return outputFilePath;
    }

    @Override
    public String packAll(String outputFolderName, boolean forceMode, String... paths) throws BinPackerException {
        ArrayList<String> pathList = new ArrayList<>(paths.length);
        pathList.addAll(Arrays.asList(paths));
        return packAll(outputFolderName, pathList, forceMode);
    }

    @Override
    public String pack(boolean forceMode, @NonNull String fileName, @NonNull String... paths) throws BinPackerException {
        return packAll(fileName, new ArrayList<>(Arrays.asList(paths)), forceMode);
    }

    @Override
    public String pack(@NonNull String fileName, @NonNull String... paths) throws BinPackerException {
        return pack(false, fileName, paths);
    }

    /**
     * This is wrapper for reusing code for processing folder (see {@link IBinPacker#packFolder(String, boolean)}) and list/Array (see {@link IBinPacker#packAll(String, ArrayList, boolean)}.
     *
     * @param fos        out stream to put data to.
     * @param root       file/dir to process (walk through).
     * @param folderPath path to store.
     * @throws IOException        when writing/reading etc. problem occurs.
     * @throws BinPackerException when some internal problem occurs.
     */
    private void walkWrapper(@NonNull FileOutputStream fos, @NonNull File root, @NonNull final String folderPath) throws IOException, BinPackerException {
        final FileOutputStream finalFos = fos;
        FileWalker.walk(root, new FileWalker.IFileWalkerAction() {
            @Override
            public void actFile(@NonNull File file) throws IOException, BinPackerException {
                String fileName = getRelativePath(folderPath, file.getAbsolutePath());
                CrashLogger.d(TAG, String.format("Absolute path:  %s", file.getAbsolutePath()));
                CrashLogger.d(TAG, String.format("Relative path: %s", fileName));
                // write file name
                finalFos.write(fileName.getBytes(UTF8));
                // zero
                finalFos.write(zeroByte);
                // size
                long size = file.length();
                CrashLogger.d(TAG, String.format("Size of file %s is: %s", fileName, size));
                finalFos.write(convertSize(size));
                // body of file
                FileInputStream fin = null;
                BinPackerException innerException = null;
                try {
                    fin = mFactory.getFileInputStream(file);
                    byte[] buffer = new byte[READ_WRITE_FILE_BUFFER_SIZE];
                    int count;
                    while ((count = fin.read(buffer)) > 0) {
                        finalFos.write(buffer, 0, count);
                    }
                } catch (IOException e) {
                    innerException = new BinPackerException(e);
                } finally {
                    handleResources(innerException, fin);
                }
            }

            @Override
            public void actFolder(@NonNull File folder) {
                // unused otherwise, will be reconstructed from relative path of files into this folder, so empty folders not included
                String relativePath = getRelativePath(folderPath, folder.getAbsolutePath());
                CrashLogger.d(TAG, String.format("Absolute path:  %s", folder.getAbsolutePath()));
                CrashLogger.d(TAG, String.format("Relative path: %s", relativePath));
            }
        });
    }

    /**
     * Check if there is file with same name and rename current one if should.
     *
     * @param filePath  to check, and rename if should.
     * @param forceMode decides whether new file will be renamed or Will overwrite old one.
     * @return current file.
     */
    @NonNull
    private File checkFileConflict(@NonNull String filePath, boolean forceMode) {
        File file = mFactory.getFile(filePath);
        if (forceMode) {
            String result = filePath;
            int counter = 0;
            while (file.exists()) {
                CrashLogger.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++;
            }
            CrashLogger.d(TAG, String.format("File will be stored as: %s", result));
        }
        return file;
    }

    /**
     * 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 BinPackerException if {@code exception} is not null or resource cannot be closed.
     */
    private void handleResources(@Nullable BinPackerException exception, @NonNull Closeable... resources) throws BinPackerException {
        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 BinPackerException(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
    @NonNull
    String mergeExceptions(@NonNull ArrayList<Exception> exceptions) {
        StringBuilder msgBuilder = new StringBuilder();
        for (Exception exception : exceptions) {
            if (exception != null) {
                msgBuilder.append(exception.getMessage());
                msgBuilder.append('\n');
            }
        }
        return msgBuilder.toString();
    }

    /**
     * Check if is called right method:
     * {@link BinPacker#packFile(String, boolean)} for file and
     * {@link BinPacker#packFolder(String, boolean)} for directory
     *
     * @param path  to check file/dir
     * @param isDir what it should be - true for directory, false otherwise
     * @throws BinPackerException if type of file given by path is not same as expected type given by {@code isDir}
     */
    private void checkCorrectness(@NonNull String path, boolean isDir) throws BinPackerException {
        File f = mFactory.getFile(path);
        if (isDir) {
            if (!f.isDirectory()) {
                throw new BinPackerException("File should be dir, but it is NOT");
            }
        } else {
            if (!f.isFile()) {
                throw new BinPackerException("File should be file, but is Directory");
            }
        }
    }

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

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

    /**
     * Try to remove extension from file name and set property if did it.
     *
     * @param name of file
     * @return name without extension
     */
    @NonNull
    private String removeExtension(@NonNull String name) {
        int lastPos = name.lastIndexOf(".");
        if (lastPos > 0) {
            mDirRemoved = true;
        }
        return name.substring(0, (lastPos < 0) ? name.length() : lastPos);
    }

    /**
     * Returns byte array holding given long
     *
     * @param size to be split to array
     */
    @NonNull
    private byte[] convertSize(long size) {
        return ByteBuffer.allocate(Long.SIZE / Byte.SIZE).putLong(size).array();
    }

    /**
     * Returns relative path from first arg to the second one.
     *
     * @param absolutePath1 actual location
     * @param absolutePath2 to go relatively.
     */
    @NonNull
    private String getRelativePath(@NonNull String absolutePath1, @NonNull String absolutePath2) {
        return mFactory.getFile(absolutePath1).toURI().relativize(mFactory.getFile(absolutePath2).toURI()).getPath();
    }

    /**
     * Inner class that holds generic functionality to walk through directory recursively.
     */
    // unit tested in separate file
    public static class FileWalker {
        /**
         * User of this class has to implement this interface that will be used to do desired action.
         */
        public interface IFileWalkerAction {
            /**
             * Is called when file is reached.
             *
             * @param file that has been found.
             * @throws IOException        will be thrown up to the {@link FileWalker#walk(File, IFileWalkerAction)} caller.
             * @throws BinPackerException will be thrown up to the {@link FileWalker#walk(File, IFileWalkerAction)} caller.
             */
            void actFile(File file) throws IOException, BinPackerException;

            /**
             * Is called when folder is reached.
             *
             * @param folder that has been found.
             * @throws BinPackerException will be thrown up to the {@link FileWalker#walk(File, IFileWalkerAction)} caller.
             */
            void actFolder(File folder) throws BinPackerException;
        }

        /**
         * Walk through file tree from given directory/file recursively to the deep and call {@link IFileWalkerAction#actFile(File)} and {@link IFileWalkerAction#actFolder(File)}.
         *
         * @param root   to start walking.
         * @param action to do when file/directory is reached.
         * @throws IOException        when some error in executing action occurs.
         * @throws BinPackerException when some error in executing action occurs.
         */
        public static void walk(@NonNull File root, @NonNull IFileWalkerAction action) throws IOException, BinPackerException {
            // directory
            if (root.isDirectory()) {
                action.actFolder(root);
                File[] files = root.listFiles();
                for (File file : files) {
                    // go through all recursively
                    walk(file, action);
                }
            } else { // file
                action.actFile(root);
            }
        }
    }

}
