package systems.crigges.jmpq3;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import systems.crigges.jmpq3.BlockTable.Block;
import systems.crigges.jmpq3.compression.RecompressOptions;
import systems.crigges.jmpq3.security.MPQEncryption;
import systems.crigges.jmpq3.security.MPQHashGenerator;

import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileChannel.MapMode;
import java.nio.channels.NonWritableChannelException;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.*;
import java.util.*;

import static systems.crigges.jmpq3.MpqFile.*;

/**
 * Provides an interface for using MPQ archive files. MPQ archive files contain
 * a virtual file system used by some old games to hold data, primarily those
 * from Blizzard Entertainment.
 * <p>
 * MPQ archives are not intended as a general purpose file system. File access
 * and reading is highly efficient. File manipulation and writing is not
 * efficient and may require rebuilding a large portion of the archive file.
 * Empty directories are not supported. The full contents of the archive might
 * not be discoverable, but such files can still be accessed if their full path
 * is known. File attributes are optional.
 * <p>
 * For platform independence the implementation is pure Java.
 */
public class JMpqEditor implements AutoCloseable {
    private Logger log = LoggerFactory.getLogger(this.getClass().getName());
    public static final int ARCHIVE_HEADER_MAGIC = ByteBuffer.wrap(new byte[]{'M', 'P', 'Q', 0x1A}).order(ByteOrder.LITTLE_ENDIAN).getInt();
    public static final int USER_DATA_HEADER_MAGIC = ByteBuffer.wrap(new byte[]{'M', 'P', 'Q', 0x1B}).order(ByteOrder.LITTLE_ENDIAN).getInt();

    /**
     * Encryption key for hash table data.
     */
    private static final int KEY_HASH_TABLE;

    /**
     * Encryption key for block table data.
     */
    private static final int KEY_BLOCK_TABLE;

    static {
        final MPQHashGenerator hasher = MPQHashGenerator.getFileKeyGenerator();
        hasher.process("(hash table)");
        KEY_HASH_TABLE = hasher.getHash();
        hasher.reset();
        hasher.process("(block table)");
        KEY_BLOCK_TABLE = hasher.getHash();
    }

    public static File tempDir;
    private AttributesFile attributes;
    /** MPQ format version 0 forced compatibility is being used. */
    private final boolean legacyCompatibility;
    /** The fc. */
    private FileChannel fc;
    /** The header offset. */
    private long headerOffset = -1;
    /** The header size. */
    private int headerSize;
    /** The archive size. */
    private long archiveSize;
    /** The format version. */
    private int formatVersion;
    /** The sector size shift */
    private int sectorSizeShift;
    /** The disc block size. */
    private int discBlockSize;
    /** The hash table file position. */
    private long hashPos;
    /** The block table file position. */
    private long blockPos;
    /** The hash size. */
    private int hashSize;
    /** The block size. */
    private int blockSize;
    /** The hash table. */
    private HashTable hashTable;
    /** The block table. */
    private BlockTable blockTable;
    /** The list file. */
    private Listfile listFile = new Listfile();
    /** The internal filename. */
    private IdentityHashMap<String, ByteBuffer> filenameToData = new IdentityHashMap<>();
    /** The files to add. */
    /** The keep header offset. */
    private boolean keepHeaderOffset = true;
    /** The new header size. */
    private int newHeaderSize;
    /** The new archive size. */
    private long newArchiveSize;
    /** The new format version. */
    private int newFormatVersion;
    /** The new disc block size. */
    private int newSectorSizeShift;
    /** The new disc block size. */
    private int newDiscBlockSize;
    /** The new hash pos. */
    private long newHashPos;
    /** The new block pos. */
    private long newBlockPos;
    /** The new hash size. */
    private int newHashSize;
    /** The new block size. */
    private int newBlockSize;

    /** If write operations are supported on the archive. */
    private boolean canWrite;

    /**
     * Creates a new MPQ editor for the MPQ file at the specified path.
     * <p>
     * If the archive file does not exist a new archive file will be created
     * automatically. Any changes made to the archive might only propagate to
     * the file system once this's close method is called.
     * <p>
     * When READ_ONLY option is specified then the archive file will never be
     * modified by this editor.
     *
     * @param mpqArchive  path to a MPQ archive file.
     * @param openOptions options to use when opening the archive.
     * @throws JMpqException if mpq is damaged or not supported.
     */
    public JMpqEditor(Path mpqArchive, MPQOpenOption... openOptions) throws JMpqException {
        // process open options
        canWrite = !Arrays.asList(openOptions).contains(MPQOpenOption.READ_ONLY);
        legacyCompatibility = Arrays.asList(openOptions).contains(MPQOpenOption.FORCE_V0);
        log.debug(mpqArchive.toString());
        try {
            setupTempDir();

            final OpenOption[] fcOptions = canWrite ? new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE}
                    : new OpenOption[]{StandardOpenOption.READ};
            fc = FileChannel.open(mpqArchive, fcOptions);

            headerOffset = searchHeader();

            readHeaderSize();

            readHeader();

            checkLegacyCompat();

            readHashTable();

            readBlockTable();

            readListFile();

            readAttributesFile();
        } catch (IOException e) {
            throw new JMpqException(mpqArchive.toAbsolutePath().toString() + ": " + e.getMessage());
        }
    }

    /**
     * See {@link #JMpqEditor(Path, MPQOpenOption...)} }
     *
     * @param mpqArchive  a MPQ archive file.
     * @param openOptions options to use when opening the archive.
     * @throws JMpqException if mpq is damaged or not supported.
     */
    public JMpqEditor(File mpqArchive, MPQOpenOption... openOptions) throws IOException {
        this(mpqArchive.toPath(), openOptions);
    }

    /**
     * See {@link #JMpqEditor(Path, MPQOpenOption...)} }
     * Kept for backwards compatibility, but deprecated
     *
     * @param mpqArchive a MPQ archive file.
     * @throws JMpqException if mpq is damaged or not supported.
     */
    @Deprecated
    public JMpqEditor(File mpqArchive) throws IOException {
        this(mpqArchive.toPath(), MPQOpenOption.FORCE_V0);
    }

    private void checkLegacyCompat() throws IOException {
        if (legacyCompatibility) {
            // limit end of archive by end of file
            archiveSize = Math.min(archiveSize, fc.size() - headerOffset);

            // limit block table size by end of archive
            blockSize = (int) (Math.min(blockSize, (archiveSize - blockPos) / 16));
        }
    }

    private void readAttributesFile() {
        if (hasFile("(attributes)")) {
            try {
                attributes = new AttributesFile(extractFileAsBytes("(attributes)"));
            } catch (Exception e) {
            }
        }
    }

    private void readListFile() {
        if (hasFile("(listfile)")) {
            try {
                File tempFile = File.createTempFile("list", "file", JMpqEditor.tempDir);
                tempFile.deleteOnExit();
                extractFile("(listfile)", tempFile);
                listFile = new Listfile(Files.readAllBytes(tempFile.toPath()));
                int hiddenFiles = (hasFile("(attributes)") ? 2 : 1) + (hasFile("(signature)") ? 1 : 0);
                if (canWrite) {
                    if (listFile.getFiles().size() >= blockTable.getAllVaildBlocks().size() - hiddenFiles) {
                        log.warn("mpq's listfile is incomplete. Blocks without listfile entry will be discarded");
                    }
                    for (String fileName : listFile.getFiles()) {
                        if (!hasFile(fileName)) {
                            log.warn("listfile entry does not exist in archive and will be discarded: " + fileName);
                        }
                    }
                    listFile.getFileMap().entrySet().removeIf(file -> !hasFile(file.getValue()));
                }
            } catch (Exception e) {
                log.warn("Extracting the mpq's listfile failed. It cannot be rebuild.", e);
            }
        } else {
            log.warn("The mpq doesn't contain a listfile. It cannot be rebuild.");
            canWrite = false;
        }
    }

    private void readBlockTable() throws IOException {
        ByteBuffer blockBuffer = ByteBuffer.allocate(blockSize * 16).order(ByteOrder.LITTLE_ENDIAN);
        fc.position(headerOffset + blockPos);
        readFully(blockBuffer, fc);
        blockBuffer.rewind();
        blockTable = new BlockTable(blockBuffer);
    }

    private void readHashTable() throws IOException {
        // read hash table
        ByteBuffer hashBuffer = ByteBuffer.allocate(hashSize * 16);
        fc.position(headerOffset + hashPos);
        readFully(hashBuffer, fc);
        hashBuffer.rewind();

        // decrypt hash table
        final MPQEncryption decrypt = new MPQEncryption(KEY_HASH_TABLE, true);
        decrypt.processSingle(hashBuffer);
        hashBuffer.rewind();

        // create hash table
        hashTable = new HashTable(hashSize);
        hashTable.readFromBuffer(hashBuffer);
    }

    private void readHeaderSize() throws IOException {
        // probe to sample file with
        ByteBuffer probe = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);
        // read header size
        fc.position(headerOffset + 4);
        readFully(probe, fc);
        headerSize = probe.getInt(0);
        if (legacyCompatibility) {
            // force version 0 header size
            headerSize = 32;
        } else if (headerSize < 32 || 208 < headerSize) {
            // header too small or too big
            throw new JMpqException("Bad header size.");
        }
    }

    private void setupTempDir() throws JMpqException {
        try {
            Path path = Paths.get(System.getProperty("java.io.tmpdir") + "jmpq");
            JMpqEditor.tempDir = path.toFile();
            if (!JMpqEditor.tempDir.exists())
                Files.createDirectory(path);

            File[] files = JMpqEditor.tempDir.listFiles();
            for (File f : files) {
                f.delete();
            }
        } catch (IOException e) {
            try {
                JMpqEditor.tempDir = Files.createTempDirectory("jmpq").toFile();
            } catch (IOException e1) {
                throw new JMpqException(e1);
            }
        }
    }

//    /**
//     * Loads a default listfile for mpqs that have none
//     * Makes the archive readonly.
//     */
//    private void loadDefaultListFile() throws IOException {
//        log.warn("The mpq doesn't come with a listfile so it cannot be rebuild");
//        InputStream resource = getClass().getClassLoader().getResourceAsStream("DefaultListfile.txt");
//        if (resource != null) {
//            File tempFile = File.createTempFile("jmpq", "lf", tempDir);
//            tempFile.deleteOnExit();
//            try (FileOutputStream out = new FileOutputStream(tempFile)) {
//                //copy stream
//                byte[] buffer = new byte[1024];
//                int bytesRead;
//                while ((bytesRead = resource.read(buffer)) != -1) {
//                    out.write(buffer, 0, bytesRead);
//                }
//            }
//            listFile = new Listfile(Files.readAllBytes(tempFile.toPath()));
//            canWrite = false;
//        }
//    }


    /**
     * Searches the file for the MPQ archive header.
     *
     * @return the file position at which the MPQ archive starts.
     * @throws IOException   if an error occurs while searching.
     * @throws JMpqException if file does not contain a MPQ archive.
     */
    private long searchHeader() throws IOException {
        // probe to sample file with
        ByteBuffer probe = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);

        final long fileSize = fc.size();
        for (long filePos = 0; filePos + probe.capacity() < fileSize; filePos += 0x200) {
            probe.rewind();
            fc.position(filePos);
            readFully(probe, fc);

            final int sample = probe.getInt(0);
            if (sample == ARCHIVE_HEADER_MAGIC) {
                // found archive header
                return filePos;
            } else if (sample == USER_DATA_HEADER_MAGIC && !legacyCompatibility) {
                // MPQ user data header with redirect to MPQ header
                // ignore in legacy compatibility mode

                // TODO process these in some meaningful way

                probe.rewind();
                fc.position(filePos + 8);
                readFully(probe, fc);

                // add header offset and align
                filePos += (probe.getInt(0) & 0xFFFFFFFFL);
                filePos &= ~(0x200 - 1);
            }
        }

        throw new JMpqException("No MPQ archive in file.");
    }

    /**
     * Read the MPQ archive header from the header chunk.
     */
    private void readHeader() throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(headerSize).order(ByteOrder.LITTLE_ENDIAN);
        readFully(buffer, fc);
        buffer.rewind();

        archiveSize = buffer.getInt() & 0xFFFFFFFFL;
        formatVersion = buffer.getShort();
        if (legacyCompatibility) {
            // force version 0 interpretation
            formatVersion = 0;
        }

        sectorSizeShift = buffer.getShort();
        discBlockSize = 512 * (1 << sectorSizeShift);
        hashPos = buffer.getInt() & 0xFFFFFFFFL;
        blockPos = buffer.getInt() & 0xFFFFFFFFL;
        hashSize = buffer.getInt() & 0x0FFFFFFF;
        blockSize = buffer.getInt();

        // version 1 extension
        if (formatVersion >= 1) {
            // TODO add high block table support
            buffer.getLong();

            // high 16 bits of file pos
            hashPos |= (buffer.getShort() & 0xFFFFL) << 32;
            blockPos |= (buffer.getShort() & 0xFFFFL) << 32;
        }

        // version 2 extension
        if (formatVersion >= 2) {
            // 64 bit archive size
            archiveSize = buffer.getLong();

            // TODO add support for BET and HET tables
            buffer.getLong();
            buffer.getLong();
        }

        // version 3 extension
        if (formatVersion >= 3) {
            // TODO add support for compression and checksums
            buffer.getLong();
            buffer.getLong();
            buffer.getLong();
            buffer.getLong();
            buffer.getLong();

            buffer.getInt();
            final byte[] md5 = new byte[16];
            buffer.get(md5);
            buffer.get(md5);
            buffer.get(md5);
            buffer.get(md5);
            buffer.get(md5);
            buffer.get(md5);
        }
    }

    /**
     * Write header.
     *
     * @param buffer the buffer
     */
    private void writeHeader(MappedByteBuffer buffer) {
        buffer.putInt(newHeaderSize);
        buffer.putInt((int) newArchiveSize);
        buffer.putShort((short) newFormatVersion);
        buffer.putShort((short) newSectorSizeShift);
        buffer.putInt((int) newHashPos);
        buffer.putInt((int) newBlockPos);
        buffer.putInt(newHashSize);
        buffer.putInt(newBlockSize);

        // TODO add full write support for versions above 1
    }

    /**
     * Calc new table size.
     */
    private void calcNewTableSize() {
        int target = listFile.getFiles().size() + 2;
        int current = 2;
        while (current < target) {
            current *= 2;
        }
        newHashSize = current * 2;
        newBlockSize = listFile.getFiles().size() + 2;
    }

    /**
     * Extract all files.
     *
     * @param dest the dest
     * @throws JMpqException the j mpq exception
     */
    public void extractAllFiles(File dest) throws JMpqException {
        if (!dest.isDirectory()) {
            throw new JMpqException("Destination location isn't a directory");
        }
        if (hasFile("(listfile)") && listFile != null) {
            for (String s : listFile.getFiles()) {
                log.debug("extracting: " + (dest.separatorChar == '\\' ? s : s.replace("\\", dest.separator)));
                File temp = new File(dest.getAbsolutePath() + dest.separator + (dest.separatorChar == '\\' ? s : s.replace("\\", dest.separator)));
                temp.getParentFile().mkdirs();
                if (hasFile(s)) {
                    // Prevent exception due to nonexistent listfile entries
                    try {
                        extractFile(s, temp);
                    } catch (JMpqException e) {
                        log.warn("File possibly corrupted and could not be extracted: " + s);
                    }
                }
            }
            if (hasFile("(attributes)")) {
                File temp = new File(dest.getAbsolutePath() + dest.separator + "(attributes)");
                extractFile("(attributes)", temp);
            }
            File temp = new File(dest.getAbsolutePath() + dest.separator + "(listfile)");
            extractFile("(listfile)", temp);
        } else {
            ArrayList<Block> blocks = blockTable.getAllVaildBlocks();
            try {
                int i = 0;
                for (Block b : blocks) {
                    if (b.hasFlag(MpqFile.ENCRYPTED)) {
                        continue;
                    }
                    ByteBuffer buf = ByteBuffer.allocate(b.getCompressedSize()).order(ByteOrder.LITTLE_ENDIAN);
                    fc.position(headerOffset + b.getFilePos());
                    readFully(buf, fc);
                    buf.rewind();
                    MpqFile f = new MpqFile(buf, b, discBlockSize, "");
                    f.extractToFile(new File(dest.getAbsolutePath() + dest.separator + i));
                    i++;
                }
            } catch (IOException e) {
                throw new JMpqException(e);
            }
        }
    }

    /**
     * Gets the total file count.
     *
     * @return the total file count
     * @throws JMpqException the j mpq exception
     */
    public int getTotalFileCount() throws JMpqException {
        return blockTable.getAllVaildBlocks().size();
    }

    /**
     * Extracts the specified file out of the mpq to the target location.
     *
     * @param name name of the file
     * @param dest destination to that the files content is written
     * @throws JMpqException if file is not found or access errors occur
     */
    public void extractFile(String name, File dest) throws JMpqException {
        try {
            MpqFile f = getMpqFile(name);
            f.extractToFile(dest);
        } catch (Exception e) {
            throw new JMpqException(e);
        }
    }

    /**
     * Extracts the specified file out of the mpq to the target location.
     *
     * @param name name of the file
     * @throws JMpqException if file is not found or access errors occur
     */
    public byte[] extractFileAsBytes(String name) throws JMpqException {
        try {
            MpqFile f = getMpqFile(name);
            return f.extractToBytes();
        } catch (IOException e) {
            throw new JMpqException(e);
        }
    }

    public String extractFileAsString(String name) throws JMpqException {
        try {
            byte[] f = extractFileAsBytes(name);
            return new String(f);
        } catch (IOException e) {
            throw new JMpqException(e);
        }
    }

    /**
     * Checks for file.
     *
     * @param name the name
     * @return true, if successful
     */
    public boolean hasFile(String name) {
        try {
            hashTable.getBlockIndexOfFile(name);
        } catch (IOException e) {
            return false;
        }
        return true;
    }

    /**
     * Gets the file names.
     *
     * @return the file names
     */
    public List<String> getFileNames() {
        return new ArrayList<>(listFile.getFiles());
    }

    /**
     * Extracts the specified file out of the mpq and writes it to the target
     * outputstream.
     *
     * @param name name of the file
     * @param dest the outputstream where the file's content is written
     * @throws JMpqException if file is not found or access errors occur
     */
    public void extractFile(String name, OutputStream dest) throws JMpqException {
        try {
            MpqFile f = getMpqFile(name);
            f.extractToOutputStream(dest);
        } catch (IOException e) {
            throw new JMpqException(e);
        }
    }

    /**
     * Gets the mpq file.
     *
     * @param name the name
     * @return the mpq file
     * @throws IOException Signals that an I/O exception has occurred.
     */
    public MpqFile getMpqFile(String name) throws IOException {
        int pos = hashTable.getBlockIndexOfFile(name);
        Block b = blockTable.getBlockAtPos(pos);

        ByteBuffer buffer = ByteBuffer.allocate(b.getCompressedSize()).order(ByteOrder.LITTLE_ENDIAN);
        fc.position(headerOffset + b.getFilePos());
        readFully(buffer, fc);
        buffer.rewind();

        return new MpqFile(buffer, b, discBlockSize, name);
    }

    /**
     * Gets the mpq file.
     *
     * @param block a block
     * @return the mpq file
     * @throws IOException Signals that an I/O exception has occurred.
     */
    public MpqFile getMpqFileByBlock(BlockTable.Block block) throws IOException {
        if (block.hasFlag(MpqFile.ENCRYPTED)) {
            throw new IOException("cant access this block");
        }
        ByteBuffer buffer = ByteBuffer.allocate(block.getCompressedSize()).order(ByteOrder.LITTLE_ENDIAN);
        fc.position(headerOffset + block.getFilePos());
        readFully(buffer, fc);
        buffer.rewind();

        return new MpqFile(buffer, block, discBlockSize, "");
    }
    
    /**
     * Gets the mpq files.
     *
     * @return the mpq files
     * @throws IOException Signals that an I/O exception has occurred.
     */
    public List<MpqFile> getMpqFilesByBlockTable() throws IOException {
        List<MpqFile> mpqFiles = new ArrayList<>();
        ArrayList<Block> list = blockTable.getAllVaildBlocks();
        for (Block block : list) {
            try {
                MpqFile mpqFile = getMpqFileByBlock(block);
                mpqFiles.add(mpqFile);
            } catch (IOException ignore) {
            }
        }
        return mpqFiles;
    }

    /**
     * Deletes the specified file out of the mpq once you rebuild the mpq.
     *
     * @param name of the file inside the mpq
     * @throws JMpqException if file is not found or access errors occur
     */
    public void deleteFile(String name) {
        if (!canWrite) {
            throw new NonWritableChannelException();
        }

        if (listFile.containsFile(name)) {
            listFile.removeFile(name);
            filenameToData.remove(name);
        }
    }

    /**
     * Inserts the specified byte array into the mpq once you close the editor.
     *
     * @param name     of the file inside the mpq
     * @param input    the input byte array
     * @param override whether to override an existing file with the same name
     * @throws IllegalArgumentException    when the mpq has filename and not override
     */
    public void insertByteArray(String name, byte[] input, boolean override) throws NonWritableChannelException,
            IllegalArgumentException {
        if (!canWrite) {
            throw new NonWritableChannelException();
        }

        if ((!override) && listFile.containsFile(name)) {
            throw new IllegalArgumentException("Archive already contains file with name: " + name);
        }

        listFile.addFile(name);
        ByteBuffer data = ByteBuffer.wrap(input);
        filenameToData.put(name, data);
    }

    /**
     * Inserts the specified byte array into the mpq once you close the editor.
     *
     * @param name  of the file inside the mpq
     * @param input the input byte array
     * @throws IllegalArgumentException    when the mpq has filename
     */
    public void insertByteArray(String name, byte[] input) throws NonWritableChannelException, IllegalArgumentException {
        insertByteArray(name, input, false);
    }
    
    /**
     * Inserts the specified file into the mpq once you close the editor.
     *
     * @param name       of the file inside the mpq
     * @param file       the file
     * @param backupFile if true the editors creates a copy of the file to add, so
     *                   further changes won't affect the resulting mpq
     */
    public void insertFile(String name, File file, boolean backupFile) throws IOException, IllegalArgumentException {
        insertFile(name, file, backupFile, false);
    }

    /**
     * Inserts the specified file into the mpq once you close the editor.
     *
     * @param name       of the file inside the mpq
     * @param file       the file
     * @param backupFile if true the editors creates a copy of the file to add, so
     *                   further changes won't affect the resulting mpq
     * @param override   whether to override an existing file with the same name
     * @throws JMpqException if file is not found or access errors occur
     */
    public void insertFile(String name, File file, boolean backupFile, boolean override) throws IOException, IllegalArgumentException {
        if (!canWrite) {
            throw new NonWritableChannelException();
        }

        log.info("insert file: " + name);

        if ((!override) && listFile.containsFile(name)) {
            throw new IllegalArgumentException("Archive already contains file with name: " + name);
        }

        try{
            listFile.addFile(name);
            if (backupFile) {
                File temp = File.createTempFile("jmpq", "backup", JMpqEditor.tempDir);
                temp.deleteOnExit();
                Files.copy(file.toPath(), temp.toPath(), StandardCopyOption.REPLACE_EXISTING);
                ByteBuffer data = ByteBuffer.wrap(Files.readAllBytes(temp.toPath()));
                filenameToData.put(name, data);
            } else {
                ByteBuffer data = ByteBuffer.wrap(Files.readAllBytes(file.toPath()));
                filenameToData.put(name, data);
            }
        } catch (IOException e) {
            throw new JMpqException(e);
        }
    }

    public void closeReadOnly() throws IOException {
        fc.close();
    }

    public void close() throws IOException {
        close(true, true, false);
    }

    public void close(boolean buildListfile, boolean buildAttributes, boolean recompress) throws IOException {
        close(buildListfile, buildAttributes, new RecompressOptions(recompress));
    }

    /**
     * @param buildListfile   whether or not to add a (listfile) to this mpq
     * @param buildAttributes whether or not to add a (attributes) file to this mpq
     * @throws IOException
     */
    public void close(boolean buildListfile, boolean buildAttributes, RecompressOptions options) throws IOException {
        // only rebuild if allowed
        if (!canWrite || !fc.isOpen()) {
            fc.close();
            log.debug("closed readonly mpq.");
            return;
        }

        long t = System.nanoTime();
        log.debug("Building mpq");
        if (listFile == null) {
            fc.close();
            return;
        }
        File temp = File.createTempFile("jmpq", "temp", JMpqEditor.tempDir);
        temp.deleteOnExit();
        FileChannel writeChannel = FileChannel.open(temp.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.READ);

        ByteBuffer headerReader = ByteBuffer.allocate((int) ((keepHeaderOffset ? headerOffset : 0) + 4)).order(ByteOrder.LITTLE_ENDIAN);
        fc.position((keepHeaderOffset ? 0 : headerOffset));
        readFully(headerReader, fc);
        headerReader.rewind();
        writeChannel.write(headerReader);

        newFormatVersion = formatVersion;
        switch (newFormatVersion) {
            case 0:
                newHeaderSize = 32;
                break;
            case 1:
                newHeaderSize = 44;
                break;
            case 2:
            case 3:
                newHeaderSize = 208;
                break;
        }
        newSectorSizeShift = options.recompress ? Math.min(options.newSectorSizeShift, 15) : sectorSizeShift;
        newDiscBlockSize = options.recompress ? 512 * (1 << newSectorSizeShift) : discBlockSize;
        calcNewTableSize();

        ArrayList<Block> newBlocks = new ArrayList<>();
        ArrayList<String> newFiles = new ArrayList<>();
        ArrayList<String> existingFiles = new ArrayList<>(listFile.getFiles());

        sortListfileEntries(existingFiles);

        log.debug("Sorted blocks");
        if (attributes != null) {
            attributes.setNames(existingFiles);
        }
        long currentPos = (keepHeaderOffset ? headerOffset : 0) + headerSize;

        for (String fileName : filenameToData.keySet()) {
            existingFiles.remove(fileName);
        }

        for (String existingName : existingFiles) {
            if (options.recompress && !existingName.endsWith(".wav")) {
                ByteBuffer extracted = ByteBuffer.wrap(extractFileAsBytes(existingName));
                filenameToData.put(existingName, extracted);
            } else {
                newFiles.add(existingName);
                int pos = hashTable.getBlockIndexOfFile(existingName);
                Block b = blockTable.getBlockAtPos(pos);
                ByteBuffer buf = ByteBuffer.allocate(b.getCompressedSize()).order(ByteOrder.LITTLE_ENDIAN);
                fc.position(headerOffset + b.getFilePos());
                readFully(buf, fc);
                buf.rewind();
                MpqFile f = new MpqFile(buf, b, discBlockSize, existingName);
                MappedByteBuffer fileWriter = writeChannel.map(MapMode.READ_WRITE, currentPos, b.getCompressedSize());
                Block newBlock = new Block(currentPos - (keepHeaderOffset ? headerOffset : 0), 0, 0, b.getFlags());
                newBlocks.add(newBlock);
                f.writeFileAndBlock(newBlock, fileWriter);
                currentPos += b.getCompressedSize();
            }
        }
        log.debug("Added existing files");
        HashMap<String, ByteBuffer> newFileMap = new HashMap<>();
        for (String newFileName : filenameToData.keySet()) {
            ByteBuffer newFile = filenameToData.get(newFileName);
            newFiles.add(newFileName);
            newFileMap.put(newFileName, newFile);
            MappedByteBuffer fileWriter = writeChannel.map(MapMode.READ_WRITE, currentPos, newFile.limit() * 2);
            Block newBlock = new Block(currentPos - (keepHeaderOffset ? headerOffset : 0), 0, 0, 0);
            newBlocks.add(newBlock);
            MpqFile.writeFileAndBlock(newFile.array(), newBlock, fileWriter, newDiscBlockSize, options);
            currentPos += newBlock.getCompressedSize();
            log.debug("Added file " + newFileName);
        }
        log.debug("Added new files");
        if (buildListfile && !listFile.getFiles().isEmpty()) {
            // Add listfile
            newFiles.add("(listfile)");
            byte[] listfileArr = listFile.asByteArray();
            MappedByteBuffer fileWriter = writeChannel.map(MapMode.READ_WRITE, currentPos, listfileArr.length * 2);
            Block newBlock = new Block(currentPos - (keepHeaderOffset ? headerOffset : 0), 0, 0, EXISTS | COMPRESSED | ENCRYPTED | ADJUSTED_ENCRYPTED);
            newBlocks.add(newBlock);
            MpqFile.writeFileAndBlock(listfileArr, newBlock, fileWriter, newDiscBlockSize, "(listfile)", options);
            currentPos += newBlock.getCompressedSize();
            log.debug("Added listfile");
        }
        // if (attributes != null) {
        // newFiles.add("(attributes)");
        // // Only generate attributes file when there has been one before
        // AttributesFile attributesFile = new AttributesFile(newFiles.size());
        // // Generate new values
        // long time = (new Date().getTime() + 11644473600000L) * 10000L;
        // for (int i = 0; i < newFiles.size() - 1; i++) {
        // String name = newFiles.get(i);
        // int entry = attributes.getEntry(name);
        // if (newFileMap.containsKey(name)){
        // // new file
        // attributesFile.setEntry(i, getCrc32(newFileMap.get(name)), time);
        // }else if (entry >= 0) {
        // // has timestamp
        // attributesFile.setEntry(i, getCrc32(name),
        // attributes.getTimestamps()[entry]);
        // } else {
        // // doesnt have timestamp
        // attributesFile.setEntry(i, getCrc32(name), time);
        // }
        // }
        // // newfiles don't contain the attributes file yet, hence -1
        // System.out.println("added attributes");
        // byte[] attrArr = attributesFile.buildFile();
        // fileWriter = writeChannel.map(MapMode.READ_WRITE, currentPos,
        // attrArr.length);
        // newBlock = new Block(currentPos - headerOffset, 0, 0, EXISTS |
        // COMPRESSED | ENCRYPTED | ADJUSTED_ENCRYPTED);
        // newBlocks.add(newBlock);
        // MpqFile.writeFileAndBlock(attrArr, newBlock, fileWriter,
        // newDiscBlockSize, "(attributes)");
        // currentPos += newBlock.getCompressedSize();
        // }

        newBlockSize = newBlocks.size();

        newHashPos = currentPos - (keepHeaderOffset ? headerOffset : 0);
        newBlockPos = newHashPos + newHashSize * 16;

        // generate new hash table
        final int hashSize = newHashSize;
        HashTable hashTable = new HashTable(hashSize);
        int blockIndex = 0;
        for (String file : newFiles) {
            hashTable.setFileBlockIndex(file, HashTable.DEFAULT_LOCALE, blockIndex++);
        }

        // prepare hashtable for writing
        final ByteBuffer hashTableBuffer = ByteBuffer.allocate(hashSize * 16);
        hashTable.writeToBuffer(hashTableBuffer);
        hashTableBuffer.flip();

        // encrypt hash table
        final MPQEncryption encrypt = new MPQEncryption(KEY_HASH_TABLE, false);
        encrypt.processSingle(hashTableBuffer);
        hashTableBuffer.flip();

        // write out hash table
        writeChannel.position(currentPos);
        writeFully(hashTableBuffer, writeChannel);
        currentPos = writeChannel.position();

        // write out block table
        MappedByteBuffer blocktableWriter = writeChannel.map(MapMode.READ_WRITE, currentPos, newBlockSize * 16);
        blocktableWriter.order(ByteOrder.LITTLE_ENDIAN);
        BlockTable.writeNewBlocktable(newBlocks, newBlockSize, blocktableWriter);
        currentPos += newBlockSize * 16;

        newArchiveSize = currentPos + 1 - (keepHeaderOffset ? headerOffset : 0);

        MappedByteBuffer headerWriter = writeChannel.map(MapMode.READ_WRITE, (keepHeaderOffset ? headerOffset : 0) + 4, headerSize + 4);
        headerWriter.order(ByteOrder.LITTLE_ENDIAN);
        writeHeader(headerWriter);

        MappedByteBuffer tempReader = writeChannel.map(MapMode.READ_WRITE, 0, currentPos + 1);
        tempReader.position(0);

        fc.position(0);
        fc.write(tempReader);
        fc.truncate(fc.position());

        fc.close();
        writeChannel.close();

        t = System.nanoTime() - t;
        log.debug("Rebuild complete. Took: " + (t / 1000000) + "ms");
    }

    private void sortListfileEntries(ArrayList<String> remainingFiles) {
        // Sort entries to preserve block table order
        remainingFiles.sort((o1, o2) -> {
            int pos1 = 999999999;
            int pos2 = 999999999;
            try {
                pos1 = hashTable.getBlockIndexOfFile(o1);
            } catch (IOException ignored) {
            }
            try {
                pos2 = hashTable.getBlockIndexOfFile(o2);
            } catch (IOException ignored) {
            }
            return pos1 - pos2;
        });
    }

    /**
     * Utility method to fill a buffer from the given channel.
     *
     * @param buffer buffer to fill.
     * @param src    channel to fill from.
     * @throws IOException  if an exception occurs when reading.
     * @throws EOFException if EoF is encountered before buffer is full or channel is non
     *                      blocking.
     */
    private static void readFully(ByteBuffer buffer, ReadableByteChannel src) throws IOException {
        while (buffer.hasRemaining()) {
            if (src.read(buffer) < 1)
                throw new EOFException("Cannot read enough bytes.");
        }
    }

    /**
     * Utility method to write out a buffer to the given channel.
     *
     * @param buffer buffer to write out.
     * @param dest   channel to write to.
     * @throws IOException if an exception occurs when writing.
     */
    private static void writeFully(ByteBuffer buffer, WritableByteChannel dest) throws IOException {
        while (buffer.hasRemaining()) {
            if (dest.write(buffer) < 1)
                throw new EOFException("Cannot write enough bytes.");
        }
    }

    /**
     * @return Whether the map can be modified or not
     */
    public boolean isCanWrite() {
        return canWrite;
    }

    /**
     * Whether or not to keep the data before the actual mpq in the file
     *
     * @param keepHeaderOffset
     */
    public void setKeepHeaderOffset(boolean keepHeaderOffset) {
        this.keepHeaderOffset = keepHeaderOffset;
    }
    
    
    /**
     * Get block table block table.
     *
     * @return the block table
     */
    public BlockTable getBlockTable() {
        return blockTable;
    }
    
    /**
     * (non-Javadoc)
     *
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {
        return "JMpqEditor [headerSize=" + headerSize + ", archiveSize=" + archiveSize + ", formatVersion=" + formatVersion + ", discBlockSize=" + discBlockSize
                + ", hashPos=" + hashPos + ", blockPos=" + blockPos + ", hashSize=" + hashSize + ", blockSize=" + blockSize + ", hashMap=" + hashTable + "]";
    }
}