/*
 ** Authored by Timothy Gerard Endres
 ** <mailto:time@gjt.org>  <http://www.trustice.com>
 **
 ** This work has been placed into the public domain.
 ** You may use this work in any way and for any purpose you wish.
 **
 ** THIS SOFTWARE IS PROVIDED AS-IS WITHOUT WARRANTY OF ANY KIND,
 ** NOT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY. THE AUTHOR
 ** OF THIS SOFTWARE, ASSUMES _NO_ RESPONSIBILITY FOR ANY
 ** CONSEQUENCE RESULTING FROM THE USE, MODIFICATION, OR
 ** REDISTRIBUTION OF THIS SOFTWARE.
 **
 */

package org.jboss.shrinkwrap.impl.base.io.tar;

import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;

import javax.activation.FileTypeMap;
import javax.activation.MimeType;
import javax.activation.MimeTypeParseException;

/**
 * The TarArchive class implements the concept of a tar archive. A tar archive is a series of entries, each of which
 * represents a file system object. Each entry in the archive consists of a header record. Directory entries consist
 * only of the header record, and are followed by entries for the directory's contents. File entries consist of a header
 * record followed by the number of records needed to contain the file's contents. All entries are written on record
 * boundaries. Records are 512 bytes long.
 *
 * TarArchives are instantiated in either read or write mode, based upon whether they are instantiated with an
 * InputStream or an OutputStream. Once instantiated TarArchives read/write mode can not be changed.
 *
 * There is currently no support for random access to tar archives. However, it seems that subclassing TarArchive, and
 * using the TarBuffer.getCurrentRecordNum() and TarBuffer.getCurrentBlockNum() methods, this would be rather trvial.
 *
 * @version $Revision: 1.15 $
 * @author Timothy Gerard Endres, <time@gjt.org>
 * @see TarBuffer
 * @see TarHeader
 * @see TarEntry
 */

public class TarArchive extends Object {
    protected boolean verbose;
    protected boolean debug;
    protected boolean keepOldFiles;
    protected boolean asciiTranslate;

    protected int userId;
    protected String userName;
    protected int groupId;
    protected String groupName;

    protected String rootPath;
    protected String tempPath;
    protected String pathPrefix;

    protected int recordSize;
    protected byte[] recordBuf;

    protected TarInputStream tarIn;
    protected TarOutputStreamImpl tarOut;

    protected TarTransFileTyper transTyper;
    protected TarProgressDisplay progressDisplay;

    /**
     * The InputStream based constructors create a TarArchive for the purposes of e'x'tracting or lis't'ing a tar
     * archive. Thus, use these constructors when you wish to extract files from or list the contents of an existing tar
     * archive.
     */

    public TarArchive(InputStream inStream) {
        this(inStream, TarBuffer.DEFAULT_BLKSIZE);
    }

    public TarArchive(InputStream inStream, int blockSize) {
        this(inStream, blockSize, TarBuffer.DEFAULT_RCDSIZE);
    }

    public TarArchive(InputStream inStream, int blockSize, int recordSize) {
        this.tarIn = new TarInputStream(inStream, blockSize, recordSize);
        this.initialize(recordSize);
    }

    /**
     * The OutputStream based constructors create a TarArchive for the purposes of 'c'reating a tar archive. Thus, use
     * these constructors when you wish to create a new tar archive and write files into it.
     */

    public TarArchive(OutputStream outStream) {
        this(outStream, TarBuffer.DEFAULT_BLKSIZE);
    }

    public TarArchive(OutputStream outStream, int blockSize) {
        this(outStream, blockSize, TarBuffer.DEFAULT_RCDSIZE);
    }

    public TarArchive(OutputStream outStream, int blockSize, int recordSize) {
        this.tarOut = new TarOutputStreamImpl(outStream, blockSize, recordSize);
        this.initialize(recordSize);
    }

    /**
     * Common constructor initialization code.
     */

    private void initialize(int recordSize) {
        this.rootPath = null;
        this.pathPrefix = null;
        this.tempPath = System.getProperty("user.dir");

        this.userId = 0;
        this.userName = "";
        this.groupId = 0;
        this.groupName = "";

        this.debug = false;
        this.verbose = false;
        this.keepOldFiles = false;
        this.progressDisplay = null;

        this.recordBuf = new byte[this.getRecordSize()];
    }

    /**
     * Set the debugging flag.
     *
     * @param debugF
     *            The new debug setting.
     */

    public void setDebug(boolean debugF) {
        this.debug = debugF;
        if (this.tarIn != null) {
            this.tarIn.setDebug(debugF);
        } else if (this.tarOut != null) {
            this.tarOut.setDebug(debugF);
        }
    }

    /**
     * Returns the verbosity setting.
     *
     * @return The current verbosity setting.
     */

    public boolean isVerbose() {
        return this.verbose;
    }

    /**
     * Set the verbosity flag.
     *
     * @param verbose
     *            The new verbosity setting.
     */

    public void setVerbose(boolean verbose) {
        this.verbose = verbose;
    }

    /**
     * Set the current progress display interface. This allows the programmer to use a custom class to display the
     * progress of the archive's processing.
     *
     * @param display
     *            The new progress display interface.
     * @see TarProgressDisplay
     */

    public void setTarProgressDisplay(TarProgressDisplay display) {
        this.progressDisplay = display;
    }

    /**
     * Set the flag that determines whether existing files are kept, or overwritten during extraction.
     *
     * @param keepOldFiles
     *            If true, do not overwrite existing files.
     */

    public void setKeepOldFiles(boolean keepOldFiles) {
        this.keepOldFiles = keepOldFiles;
    }

    /**
     * Set the ascii file translation flag. If ascii file translatio is true, then the MIME file type will be consulted
     * to determine if the file is of type 'text/*'. If the MIME type is not found, then the TransFileTyper is consulted
     * if it is not null. If either of these two checks indicates the file is an ascii text file, it will be translated.
     * The translation converts the local operating system's concept of line ends into the UNIX line end, '\n', which is
     * the defacto standard for a TAR archive. This makes text files compatible with UNIX, and since most tar
     * implementations for other platforms, compatible with most other platforms.
     *
     * @param asciiTranslate
     *            If true, translate ascii text files.
     */

    public void setAsciiTranslation(boolean asciiTranslate) {
        this.asciiTranslate = asciiTranslate;
    }

    /**
     * Set the object that will determine if a file is of type ascii text for translation purposes.
     *
     * @param transTyper
     *            The new TransFileTyper object.
     */

    public void setTransFileTyper(TarTransFileTyper transTyper) {
        this.transTyper = transTyper;
    }

    /**
     * Set user and group information that will be used to fill in the tar archive's entry headers. Since Java currently
     * provides no means of determining a user name, user id, group name, or group id for a given File, TarArchive
     * allows the programmer to specify values to be used in their place.
     *
     * @param userId
     *            The user Id to use in the headers.
     * @param userName
     *            The user name to use in the headers.
     * @param groupId
     *            The group id to use in the headers.
     * @param groupName
     *            The group name to use in the headers.
     */

    public void setUserInfo(int userId, String userName, int groupId, String groupName) {
        this.userId = userId;
        this.userName = userName;
        this.groupId = groupId;
        this.groupName = groupName;
    }

    /**
     * Get the user id being used for archive entry headers.
     *
     * @return The current user id.
     */

    public int getUserId() {
        return this.userId;
    }

    /**
     * Get the user name being used for archive entry headers.
     *
     * @return The current user name.
     */

    public String getUserName() {
        return this.userName;
    }

    /**
     * Get the group id being used for archive entry headers.
     *
     * @return The current group id.
     */

    public int getGroupId() {
        return this.groupId;
    }

    /**
     * Get the group name being used for archive entry headers.
     *
     * @return The current group name.
     */

    public String getGroupName() {
        return this.groupName;
    }

    /**
     * Get the current temporary directory path. Because Java's File did not support temporary files until version 1.2,
     * TarArchive manages its own concept of the temporary directory. The temporary directory defaults to the 'user.dir'
     * System property.
     *
     * @return The current temporary directory path.
     */

    public String getTempDirectory() {
        return this.tempPath;
    }

    /**
     * Set the current temporary directory path.
     *
     * @param path
     *            The new temporary directory path.
     */

    public void setTempDirectory(String path) {
        this.tempPath = path;
    }

    /**
     * Get the archive's record size. Because of its history, tar supports the concept of buffered IO consisting of
     * BLOCKS of RECORDS. This allowed tar to match the IO characteristics of the physical device being used. Of course,
     * in the Java world, this makes no sense, WITH ONE EXCEPTION - archives are expected to be propertly "blocked".
     * Thus, all of the horrible TarBuffer support boils down to simply getting the "boundaries" correct.
     *
     * @return The record size this archive is using.
     */

    public int getRecordSize() {
        if (this.tarIn != null) {
            return this.tarIn.getRecordSize();
        } else if (this.tarOut != null) {
            return this.tarOut.getRecordSize();
        }

        return TarBuffer.DEFAULT_RCDSIZE;
    }

    /**
     * Get a path for a temporary file for a given File. The temporary file is NOT created. The algorithm attempts to
     * handle filename collisions so that the name is unique.
     *
     * @return The temporary file's path.
     */

    private String getTempFilePath(File eFile) {
        String pathStr = this.tempPath + File.separator + eFile.getName() + ".tmp";

        for (int i = 1; i < 5; ++i) {
            File f = new File(pathStr);

            if (!f.exists()) {
                break;
            }

            pathStr = this.tempPath + File.separator + eFile.getName() + "-" + i + ".tmp";
        }

        return pathStr;
    }

    /**
     * Close the archive. This simply calls the underlying tar stream's close() method.
     */

    public void closeArchive() throws IOException {
        if (this.tarIn != null) {
            this.tarIn.close();
        } else if (this.tarOut != null) {
            this.tarOut.close();
        }
    }

    /**
     * Perform the "list" command and list the contents of the archive. NOTE That this method uses the progress display
     * to actually list the conents. If the progress display is not set, nothing will be listed!
     */

    public void listContents() throws IOException {
        for (;;) {
            TarEntry entry = this.tarIn.getNextEntry();

            if (entry == null) {
                if (this.debug) {
                    System.err.println("READ EOF RECORD");
                }
                break;
            }

            if (this.progressDisplay != null) {
                this.progressDisplay.showTarProgressMessage(entry.getName());
            }
        }
    }

    /**
     * Perform the "extract" command and extract the contents of the archive.
     *
     * @param destDir
     *            The destination directory into which to extract.
     */

    public void extractContents(File destDir) throws IOException {
        for (;;) {
            TarEntry entry = this.tarIn.getNextEntry();

            if (entry == null) {
                if (this.debug) {
                    System.err.println("READ EOF RECORD");
                }
                break;
            }

            this.extractEntry(destDir, entry);
        }
    }

    /**
     * Extract an entry from the archive. This method assumes that the tarIn stream has been properly set with a call to
     * getNextEntry().
     *
     * @param destDir
     *            The destination directory into which to extract.
     * @param entry
     *            The TarEntry returned by tarIn.getNextEntry().
     */

    private void extractEntry(File destDir, TarEntry entry) throws IOException {
        if (this.verbose) {
            if (this.progressDisplay != null) {
                this.progressDisplay.showTarProgressMessage(entry.getName());
            }
        }

        String name = entry.getName();
        name = name.replace('/', File.separatorChar);

        File destFile = new File(destDir, name);

        if (entry.isDirectory()) {
            if (!destFile.exists()) {
                if (!destFile.mkdirs()) {
                    throw new IOException("error making directory path '" + destFile.getPath() + "'");
                }
            }
        } else {
            File subDir = new File(destFile.getParent());

            if (!subDir.exists()) {
                if (!subDir.mkdirs()) {
                    throw new IOException("error making directory path '" + subDir.getPath() + "'");
                }
            }

            if (this.keepOldFiles && destFile.exists()) {
                if (this.verbose) {
                    if (this.progressDisplay != null) {
                        this.progressDisplay.showTarProgressMessage("not overwriting " + entry.getName());
                    }
                }
            } else {
                boolean asciiTrans = false;

                FileOutputStream out = new FileOutputStream(destFile);

                if (this.asciiTranslate) {
                    MimeType mime = null;
                    String contentType = null;

                    try {
                        contentType = FileTypeMap.getDefaultFileTypeMap().getContentType(destFile);

                        mime = new MimeType(contentType);

                        if (mime.getPrimaryType().equalsIgnoreCase("text")) {
                            asciiTrans = true;
                        } else if (this.transTyper != null) {
                            if (this.transTyper.isAsciiFile(entry.getName())) {
                                asciiTrans = true;
                            }
                        }
                    } catch (MimeTypeParseException ex) {
                    }

                    if (this.debug) {
                        System.err.println("EXTRACT TRANS? '" + asciiTrans + "'  ContentType='" + contentType
                            + "'  PrimaryType='" + mime.getPrimaryType() + "'");
                    }
                }

                PrintWriter outw = null;
                if (asciiTrans) {
                    outw = new PrintWriter(out);
                }

                byte[] rdbuf = new byte[32 * 1024];

                for (;;) {
                    int numRead = this.tarIn.read(rdbuf);

                    if (numRead == -1) {
                        break;
                    }

                    if (asciiTrans) {
                        for (int off = 0, b = 0; b < numRead; ++b) {
                            if (rdbuf[b] == 10) {
                                String s = new String(rdbuf, off, (b - off));

                                outw.println(s);

                                off = b + 1;
                            }
                        }
                    } else {
                        out.write(rdbuf, 0, numRead);
                    }
                }

                if (asciiTrans) {
                    outw.close();
                } else {
                    out.close();
                }
            }
        }
    }

    /**
     * Write an entry to the archive. This method will call the putNextEntry() and then write the contents of the entry,
     * and finally call closeEntry() for entries that are files. For directories, it will call putNextEntry(), and then,
     * if the recurse flag is true, process each entry that is a child of the directory.
     *
     * @param entry
     *            The TarEntry representing the entry to write to the archive.
     * @param recurse
     *            If true, process the children of directory entries.
     */

    public void writeEntry(TarEntry oldEntry, boolean recurse) throws IOException {
        boolean asciiTrans = false;
        boolean unixArchiveFormat = oldEntry.isUnixTarFormat();

        File tFile = null;
        File eFile = oldEntry.getFile();

        // Work on a copy of the entry so we can manipulate it.
        // Note that we must distinguish how the entry was constructed.
        //
        TarEntry entry = (TarEntry) oldEntry.clone();

        if (this.verbose) {
            if (this.progressDisplay != null) {
                this.progressDisplay.showTarProgressMessage(entry.getName());
            }
        }

        if (this.asciiTranslate && !entry.isDirectory()) {
            MimeType mime = null;
            String contentType = null;

            try {
                contentType = FileTypeMap.getDefaultFileTypeMap().getContentType(eFile);

                mime = new MimeType(contentType);

                if (mime.getPrimaryType().equalsIgnoreCase("text")) {
                    asciiTrans = true;
                } else if (this.transTyper != null) {
                    if (this.transTyper.isAsciiFile(eFile)) {
                        asciiTrans = true;
                    }
                }
            } catch (MimeTypeParseException ex) {
                // IGNORE THIS ERROR...
            }

            if (this.debug) {
                System.err.println("CREATE TRANS? '" + asciiTrans + "'  ContentType='" + contentType
                    + "'  PrimaryType='" + mime.getPrimaryType() + "'");
            }

            if (asciiTrans) {
                String tempFileName = this.getTempFilePath(eFile);

                tFile = new File(tempFileName);

                BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(eFile)));

                BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(tFile));

                for (;;) {
                    String line = in.readLine();
                    if (line == null) {
                        break;
                    }

                    out.write(line.getBytes());
                    out.write((byte) '\n');
                }

                in.close();
                out.flush();
                out.close();

                entry.setSize(tFile.length());

                eFile = tFile;
            }
        }

        String newName = null;

        if (this.rootPath != null) {
            if (entry.getName().startsWith(this.rootPath)) {
                newName = entry.getName().substring(this.rootPath.length() + 1);
            }
        }

        if (this.pathPrefix != null) {
            newName = (newName == null) ? this.pathPrefix + "/" + entry.getName() : this.pathPrefix + "/" + newName;
        }

        if (newName != null) {
            entry.setName(newName);
        }

        this.tarOut.putNextEntry(entry);

        if (entry.isDirectory()) {
            if (recurse) {
                TarEntry[] list = entry.getDirectoryEntries();

                for (int i = 0; i < list.length; ++i) {
                    TarEntry dirEntry = list[i];

                    if (unixArchiveFormat) {
                        dirEntry.setUnixTarFormat();
                    }

                    this.writeEntry(dirEntry, recurse);
                }
            }
        } else {
            FileInputStream in = new FileInputStream(eFile);

            byte[] eBuf = new byte[32 * 1024];
            for (;;) {
                int numRead = in.read(eBuf, 0, eBuf.length);

                if (numRead == -1) {
                    break;
                }

                this.tarOut.write(eBuf, 0, numRead);
            }

            in.close();

            if (tFile != null) {
                tFile.delete();
            }

            this.tarOut.closeEntry();
        }
    }

}
