/*-
 * Copyright (C) 2008 Erik Larsson
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.catacombae.hfsexplorer.fs;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import org.catacombae.hfsexplorer.types.applesingle.AppleSingleHeader;
import org.catacombae.hfsexplorer.types.applesingle.AttributeEntry;
import org.catacombae.hfsexplorer.types.applesingle.AttributeHeader;
import org.catacombae.hfsexplorer.types.applesingle.EntryDescriptor;
import org.catacombae.util.Util;
import org.catacombae.util.Util.Pair;

/**
 * @author <a href="https://catacombae.org" target="_top">Erik Larsson</a>
 */
public class AppleSingleBuilder {
    /*
     * Layout of an AppleSingle file:
     * -------------------------------|
     * | AppleSingleHeader (26 bytes) |
     * |------------------------------|
     * | EntryDescriptor[0] (12 bytes)|
     * --------------------------------
     * :                              : Number of entries is determined by the numEntries field in
     * -------------------------------| the header.
     * | EntryDescriptor[n]           |
     * |------------------------------|
     * :                              : <- the format allows for padding
     * |------------------------------| <- pos, len determined by EntryDescriptor[0]
     * | entry[0]                     |
     * :                              :
     * -------------------------------| <- pos, len determined by EntryDescriptor[n]
     * | entry[n]                     |
     * :                              :
     * -------------------------------|
     */

    /**
     * Type enumerating all valid file types for an AppleSingle file.
     */
    public static enum FileType {
        /**
         * Indicates an AppleSingle file, containing both a data fork and a resource fork (and
         * optionally other attributes).
         */
        APPLESINGLE(0x00051600),
        /**
         * Indicates an AppleDouble file, containing only a resource fork (and optionally other
         * attributes). The same file format (AppleSingle) is used, but the data fork is omitted.
         */
        APPLEDOUBLE(0x00051607);

        private final int magic;

        private FileType(int magic) {
            this.magic = magic;
        }

        /**
         * Returns the magic number associated with this file type.
         * @return the magic number associated with this file type.
         */
        public int getMagic() { return magic; }
    }

    public static enum AppleSingleVersion {
        /** The AppleSingle format used in A/UX and possibly Mac OS Classic / early Mac OS X versions. */
        VERSION_1_0(0x00010000),
        /** The version used in Mac OS X Leopard and possibly earlier Mac OS X versions. */
        VERSION_2_0(0x00020000);

        private final int versionNumber;

        private AppleSingleVersion(int versionNumber) {
            this.versionNumber = versionNumber;
        }

        public int getVersionNumber() {
            return versionNumber;
        }
    }

    public static enum FileSystem {
        MACOS("Macintosh"),
        MACOS_X("Mac OS X"),
        PRODOS("ProDOS"),
        MS_DOS("MS-DOS"),
        UNIS("Unix"),
        VMS("VAX VMS");

        private final String identifier;

        private FileSystem(String identifier) {
            this.identifier = identifier;
        }

        public byte[] getIdentifier() {
            char[] chars = identifier.toCharArray();
            byte[] result = new byte[16];
            for(int i = 0; i < result.length; ++i) {
                if(i < chars.length)
                    result[i] = (byte)(chars[i] & 0x7F);
                else
                    result[i] = (byte)' '; // Padding with spaces
            }
            return result;
        }
    }

    public static enum EntryType {
        DATA_FORK(EntryDescriptor.ENTRY_ID_DATA),
        RESOURCE_FORK(EntryDescriptor.ENTRY_ID_RESOURCE),
        REAL_NAME(EntryDescriptor.ENTRY_ID_REALNAME),
        COMMENT(EntryDescriptor.ENTRY_ID_COMMENT),
        ICON_BW(EntryDescriptor.ENTRY_ID_ICONBW),
        ICON_COLOR(EntryDescriptor.ENTRY_ID_ICONCOLOR),
        FILE_INFO(EntryDescriptor.ENTRY_ID_UNUSED),
        FINDER_INFO(EntryDescriptor.ENTRY_ID_FINDERINFO);

        private final int typeNumber;

        private EntryType(int typeNumber) {
            this.typeNumber = typeNumber;
        }

        public int getTypeNumber() { return typeNumber; }
    }

    private static final byte[] EMPTY_RESOURCE_FORK = {
        (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x1e,
        (byte) 0x54, (byte) 0x68, (byte) 0x69, (byte) 0x73,
        (byte) 0x20, (byte) 0x72, (byte) 0x65, (byte) 0x73,
        (byte) 0x6f, (byte) 0x75, (byte) 0x72, (byte) 0x63,
        (byte) 0x65, (byte) 0x20, (byte) 0x66, (byte) 0x6f,
        (byte) 0x72, (byte) 0x6b, (byte) 0x20, (byte) 0x69,
        (byte) 0x6e, (byte) 0x74, (byte) 0x65, (byte) 0x6e,
        (byte) 0x74, (byte) 0x69, (byte) 0x6f, (byte) 0x6e,
        (byte) 0x61, (byte) 0x6c, (byte) 0x6c, (byte) 0x79,
        (byte) 0x20, (byte) 0x6c, (byte) 0x65, (byte) 0x66,
        (byte) 0x74, (byte) 0x20, (byte) 0x62, (byte) 0x6c,
        (byte) 0x61, (byte) 0x6e, (byte) 0x6b, (byte) 0x20,
        (byte) 0x20, (byte) 0x20, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x1e,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
        (byte) 0x00, (byte) 0x1c, (byte) 0x00, (byte) 0x1e,
        (byte) 0xff, (byte) 0xff,
    };

    private static final int ALIGNMENT = 4096;

    private final FileType fileType;
    private final AppleSingleVersion version;
    private final FileSystem homeFileSystem;
    private final LinkedList<Pair<EntryType, AppleSingleEntry>> entryList =
            new LinkedList<Pair<EntryType, AppleSingleEntry>>();

    public AppleSingleBuilder(FileType fileType, AppleSingleVersion version, FileSystem homeFileSystem) {
        if(fileType == null)
            throw new IllegalArgumentException("fileType == null");
        if(version == null)
            throw new IllegalArgumentException("version == null");
        if(homeFileSystem == null)
            throw new IllegalArgumentException("homeFileSystem == null");
        this.fileType = fileType;
        this.version = version;
        this.homeFileSystem = homeFileSystem;
    }

    public void addDataFork(byte[] resourceForkData) {
        entryList.add(new Pair<EntryType, AppleSingleEntry>(EntryType.DATA_FORK,
                new RawDataEntry(resourceForkData)));
    }

    public void addResourceFork(byte[] resourceForkData) {
        entryList.add(new Pair<EntryType, AppleSingleEntry>(
                EntryType.RESOURCE_FORK, new RawDataEntry(resourceForkData)));
    }

    public void addEmptyResourceFork() {
        entryList.add(new Pair<EntryType, AppleSingleEntry>(
                EntryType.RESOURCE_FORK,
                new RawDataEntry(EMPTY_RESOURCE_FORK)));
    }

    public void addFinderInfo(byte[] finderInfoData,
            List<Pair<String, byte[]>> extendedAttributeList)
    {
        if(finderInfoData != null && finderInfoData.length != 32) {
            throw new IllegalArgumentException("Incorrect Finder info data " +
                    "length (expected: 32, actual: " + finderInfoData.length +
                    ").");
        }

        ArrayList<Pair<byte[], byte[]>> attributeDataList =
                new ArrayList<Pair<byte[], byte[]>>(
                        extendedAttributeList.size());

        for(Pair<String, byte[]> extendedAttributePair : extendedAttributeList)
        {
            /* Note: Is normalization necessary? Other substitutions? */
            byte[] attributeNameUtf8 =
                    Util.encodeString(extendedAttributePair.getA() + "\0",
                    "UTF-8");
            if(attributeNameUtf8.length > 255) {
                throw new RuntimeException("Extended attribute name " +
                        "\"" + extendedAttributePair.getA() + "\" is too " +
                        "long (maximum length: 255, actual length: " +
                        attributeNameUtf8.length + ").");
            }

            byte[] attributeData = extendedAttributePair.getB();

            /* Because of limitations in the XNU kernel's AppleDouble
             * implementation, the maximum data size of extended attributes
             * stored inside an AppleDouble file is 128 KiB. */
            if(attributeData.length > (128 * 1024)) {
                throw new RuntimeException("Attribute data size is too large " +
                        "to be stored in an AppleDouble file (maximum " +
                        "allowed: " + (128 * 1024) + ", actual: " +
                        attributeData.length + ").");
            }

            attributeDataList.add(new Pair<byte[], byte[]>(attributeNameUtf8,
                    extendedAttributePair.getB()));

        }

        entryList.add(new Pair<EntryType, AppleSingleEntry>(
                EntryType.FINDER_INFO,
                new FinderInfoEntry(finderInfoData, attributeDataList)));
    }

    /**
     * Serializes the current state of the builder into a valid AppleSingle data representation that
     * can be written down to file.
     *
     * @return the data of an AppleSingle format file built from the current state of the builder.
     */
    public byte[] getResult() {
        int dataSize = AppleSingleHeader.length();
        dataSize += EntryDescriptor.length()*entryList.size();
        int dataStartOffset = dataSize;

        for(Pair<EntryType, AppleSingleEntry> p : entryList) {
            dataSize += p.getB().getBytes(dataSize, null, 0);
        }

        /* Adjust dataSize for alignment. */
        int remainingAlignmentSize = (ALIGNMENT - (dataSize % ALIGNMENT));
        dataSize = dataSize + remainingAlignmentSize;

        byte[] result = new byte[dataSize];
        int pointer = 0;
        {
            AppleSingleHeader header = new AppleSingleHeader(fileType.getMagic(),
                    version.getVersionNumber(), homeFileSystem, entryList.size());
            byte[] headerData = header.getBytes();
            System.arraycopy(headerData, 0, result, pointer, headerData.length);
            pointer += headerData.length;
        }

        final EntryDescriptor[] entryDescriptors =
                new EntryDescriptor[entryList.size()];
        int i = 0;
        int dataOffset = dataStartOffset;
        for(Pair<EntryType, AppleSingleEntry> p : entryList) {
            int entryDataLength = p.getB().getBytes(dataOffset, null, 0);
            if(p.getA() == EntryType.FINDER_INFO) {
                /* If we have a Finder info entry, then make sure that all the
                 * alignment padding is allocated to this entry. This is done in
                 * order to have headroom for expansion of extended attribute
                 * list/data. */
                entryDataLength += remainingAlignmentSize;
                remainingAlignmentSize = 0;
            }

            EntryDescriptor ed = new EntryDescriptor(p.getA().getTypeNumber(),
                    dataOffset, entryDataLength);
            entryDescriptors[i++] = ed;
            dataOffset += entryDataLength;

            byte[] entryDescriptorData = ed.getBytes();
            System.arraycopy(entryDescriptorData, 0, result, pointer, entryDescriptorData.length);
            pointer += entryDescriptorData.length;
        }

        if(pointer != dataStartOffset)
            throw new RuntimeException("Internal error: Miscalculation of dataStartOffset (should be: " +
                    pointer + ", was: " + dataStartOffset + ")");

        i = 0;
        for(Pair<EntryType, AppleSingleEntry> p : entryList) {
            final EntryDescriptor desc = entryDescriptors[i++];
            final int entryOffset = desc.getEntryOffset();
            final int entryLength = desc.getEntryLength();

            if(pointer != entryOffset) {
                throw new RuntimeException("Internal error: Miscalculation " +
                        "of data offset for entry " + i + " (calculated: " +
                        entryOffset + ", actual: " + pointer + ").");
            }

            final int entryDataLength =
                    p.getB().getBytes(pointer, result, pointer);
            pointer += entryDataLength;

            final int entryPaddingLength = entryLength - entryDataLength;
            if(entryPaddingLength != 0) {
                /* Fill trailing bytes in entry with zeroed data. */
                Arrays.fill(result, pointer, pointer + entryPaddingLength,
                        (byte) 0);
                pointer += entryPaddingLength;
            }
        }

        final int trailingPaddingLength = result.length - pointer;
        if(trailingPaddingLength != 0) {
            /* Fill trailing bytes in file with zeroed data. */
            Arrays.fill(result, pointer, pointer + trailingPaddingLength,
                    (byte) 0);
            pointer += trailingPaddingLength;
        }

        return result;
    }

    public interface AppleSingleEntry {
        public int getBytes(long fileOffset, byte[] data, int offset);
    }

    public class RawDataEntry implements AppleSingleEntry {
        private final byte[] rawData;

        public RawDataEntry(byte[] rawData) {
            this.rawData = rawData;
        }

        public int getBytes(long fileOffset, byte[] data, int offset) {
            if(data != null) {
                System.arraycopy(rawData, 0, data, offset, rawData.length);
            }

            return rawData.length;
        }
    }

    public class FinderInfoEntry implements AppleSingleEntry {
        private final byte[] finderInfoData;
        private final ArrayList<Pair<byte[], byte[]>> attributeDataList;

        private FinderInfoEntry(byte[] finderInfoData,
                ArrayList<Pair<byte[], byte[]>> attributeDataList)
        {
            this.finderInfoData = finderInfoData;
            this.attributeDataList = attributeDataList;
        }

        public int getBytes(long fileOffset, byte[] data, int offset) {
            int finderInfoDataSize = 0;

            if(data != null) {
                if(finderInfoData != null) {
                    System.arraycopy(finderInfoData, 0, data,
                            offset + finderInfoDataSize, 32);
                }
                else {
                    Arrays.fill(data, offset + finderInfoDataSize,
                            offset + finderInfoDataSize + 32, (byte) 0);
                }
            }

            finderInfoDataSize += 32;

            if(!attributeDataList.isEmpty()) {
                /* 2-byte padding. */
                if(data != null) {
                    Arrays.fill(data, offset + finderInfoDataSize,
                            offset + finderInfoDataSize + 2, (byte) 0);
                }

                finderInfoDataSize += 2;

                /* Calculate offsets of header and entries. This is non-trivial
                 * due to the requirement that entries are variable-sized and
                 * must be 4-byte aligned. */
                final int attributeHeaderOffset = offset + finderInfoDataSize;
                finderInfoDataSize += AttributeHeader.STRUCTSIZE;

                final int[] attributeEntryOffsets =
                        data != null ? new int[attributeDataList.size()] : null;
                int extendedAttributesDataSize = 0;
                int i = 0;
                for(Pair<byte[], byte[]> attributeData : attributeDataList) {
                    extendedAttributesDataSize += attributeData.getB().length;

                    /* Align to 4-byte boundaries. */
                    final int remainder =
                            (int) ((fileOffset + finderInfoDataSize) & 0x3);
                    if(remainder != 0) {
                        if(data != null) {
                            Arrays.fill(data, offset + finderInfoDataSize,
                                    offset + finderInfoDataSize + remainder,
                                    (byte) 0);
                        }

                        finderInfoDataSize += remainder;
                    }

                    if(data != null) {
                        attributeEntryOffsets[i++] =
                                offset + finderInfoDataSize;
                    }

                    finderInfoDataSize += AttributeEntry.STATIC_STRUCTSIZE +
                            attributeData.getA().length;
                }

                /* Header + entries must fit within the first 64 KiB of the file
                 * beacuse of limitations in the XNU AppleDouble
                 * implementation. */
                if(fileOffset + finderInfoDataSize > 65536) {
                    throw new RuntimeException("Attribute entry list extends " +
                            "beyond the first 64k of the file (ends at: " +
                            (fileOffset + finderInfoDataSize) + ").");
                }

                final int extendedAttributesDataStart =
                        (int) (fileOffset + finderInfoDataSize);
                int curDataOffset = finderInfoDataSize;

                /* Write out attribute header. */
                if(data != null) {
                    final AttributeHeader header = new AttributeHeader(0,
                            extendedAttributesDataStart +
                            extendedAttributesDataSize,
                            extendedAttributesDataStart,
                            extendedAttributesDataSize,
                            (short) 0, (short) attributeDataList.size());
                    final byte[] headerBytes = header.getBytes();
                    System.arraycopy(headerBytes, 0, data,
                            attributeHeaderOffset, headerBytes.length);
                }

                /* Write out attribute entry headers and associated data. */
                i = 0;
                for(Pair<byte[], byte[]> attributeData : attributeDataList) {
                    final byte[] content = attributeData.getB();

                    if(data != null) {
                        final byte[] name = attributeData.getA();

                        final AttributeEntry entry = new AttributeEntry(
                                curDataOffset, content.length, (short) 0, name,
                                0, (short) name.length);
                        final byte[] entryData = entry.getBytes();

                        /* Write out entry header. */
                        System.arraycopy(entryData, 0, data,
                                attributeEntryOffsets[i++], entryData.length);

                        /* Write out entry data. */
                        System.arraycopy(content, 0, data,
                                offset + finderInfoDataSize, content.length);
                    }

                    finderInfoDataSize += content.length;
                }
            }

            return finderInfoDataSize;
        }
    }
}
