/*  
 * Copyright 2008 CoreMedia AG, Hamburg
 *
 * Licensed under the Apache License, Version 2.0 (the License); 
 * you may not use this file except in compliance with the License. 
 * You may obtain a copy of the License at 
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0 
 * 
 * Unless required by applicable law or agreed to in writing, software 
 * distributed under the License is distributed on an AS IS BASIS, 
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
 * See the License for the specific language governing permissions and 
 * limitations under the License. 
 */

package com.googlecode.mp4parser.boxes.microsoft;


import com.googlecode.mp4parser.AbstractBox;

import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Date;
import java.util.Vector;


/**
 * <h1>4cc = "{@value #TYPE}"</h1>
 * Windows Media Xtra Box.
 *
 * I can't find definitive documentation on this from Microsoft so it's cobbled together from
 * various sources. Mostly ExifTool for Perl.
 *
 * Various references:
 * https://msdn.microsoft.com/en-us/library/windows/desktop/dd743066(v=vs.85).aspx
 * https://metacpan.org/source/EXIFTOOL/Image-ExifTool-9.76/lib/Image/ExifTool/Microsoft.pm
 * http://www.ventismedia.com/mantis/view.php?id=12017
 * http://www.hydrogenaudio.org/forums/index.php?showtopic=75123&amp;st=250
 * http://www.mediamonkey.com/forum/viewtopic.php?f=1&amp;t=76321
 * https://code.google.com/p/mp4v2/issues/detail?id=113
 *
 * @author marwatk
 */

public class XtraBox extends AbstractBox {
    public static final String TYPE = "Xtra";

    public static final int MP4_XTRA_BT_UNICODE = 8;
    public static final int MP4_XTRA_BT_INT64 = 19;
    public static final int MP4_XTRA_BT_FILETIME = 21;
    public static final int MP4_XTRA_BT_GUID = 72;

    private boolean successfulParse = false;

    Vector<XtraTag> tags = new Vector<XtraTag>();

    ByteBuffer data;

    public XtraBox() {
        super("Xtra");

    }

    public XtraBox(String type) {
        super(type);
    }

    @Override
    protected long getContentSize() {
        if (successfulParse) {
            return detailSize();
        } else {
            return data.limit();
        }
    }

    private int detailSize() {
        int size = 0;
        for (int i = 0; i < tags.size(); i++) {
            size += tags.elementAt(i).getContentSize();
        }
        return size;

    }

    public String toString() {
        if (!this.isParsed()) {
            this.parseDetails();
        }
        StringBuffer b = new StringBuffer();
        b.append("XtraBox[");
        for (XtraTag tag : tags) {
            for (XtraValue value : tag.values) {
                b.append(tag.tagName);
                b.append("=");
                b.append(value.toString());
                b.append(";");
            }
        }
        b.append("]");
        return b.toString();
    }


    @Override
    public void _parseDetails(ByteBuffer content) {
        int boxSize = content.remaining();
        data = content.slice(); //Keep this in case we fail to parse
        successfulParse = false;
        try {
            tags.clear();
            while (content.remaining() > 0) {
                XtraTag tag = new XtraTag();
                tag.parse(content);
                tags.addElement(tag);
            }
            int calcSize = detailSize();
            if (boxSize != calcSize) {
                throw new RuntimeException("Improperly handled Xtra tag: Calculated sizes don't match ( " + boxSize + "/" + calcSize + ")");
            }
            successfulParse = true;
        } catch (Exception e) {
            successfulParse = false;
            System.err.println("Malformed Xtra Tag detected: " + e.toString());
            e.printStackTrace();
            content.position(content.position() + content.remaining());
        } finally {
            content.order(ByteOrder.BIG_ENDIAN); //Just in case we bailed out mid-parse we don't want to leave the byte order in MS land
        }
    }

    @Override
    protected void getContent(ByteBuffer byteBuffer) {
        if (successfulParse) {
            for (int i = 0; i < tags.size(); i++) {
                tags.elementAt(i).getContent(byteBuffer);
            }
        } else {
            data.rewind();
            byteBuffer.put(data);
        }
    }

    /**
     * Returns a list of the tag names present in this Xtra Box
     *
     * @return Possibly empty (zero length) array of tag names present
     */
    public String[] getAllTagNames() {
        String names[] = new String[tags.size()];
        for (int i = 0; i < tags.size(); i++) {
            XtraTag tag = tags.elementAt(i);
            names[i] = tag.tagName;
        }
        return names;
    }

    /**
     * Returns the first String value found for this tag
     *
     * @param name Tag name
     * @return First String value found
     */
    public String getFirstStringValue(String name) {
        Object objs[] = getValues(name);
        for (Object obj : objs) {
            if (obj instanceof String) {
                return (String) obj;
            }
        }
        return null;
    }


    /**
     * Returns the first Date value found for this tag
     *
     * @param name Tag name
     * @return First Date value found
     */
    public Date getFirstDateValue(String name) {
        Object objs[] = getValues(name);
        for (Object obj : objs) {
            if (obj instanceof Date) {
                return (Date) obj;
            }
        }
        return null;
    }


    /**
     * Returns the first Long value found for this tag
     *
     * @param name Tag name
     * @return First long value found
     */
    public Long getFirstLongValue(String name) {
        Object objs[] = getValues(name);
        for (Object obj : objs) {
            if (obj instanceof Long) {
                return (Long) obj;
            }
        }
        return null;
    }

    /**
     * Returns an array of values for this tag. Empty array when tag is not present
     *
     * @param name Tag name to retrieve
     * @return Possibly empty array of values (possible types are String, Long, Date and byte[] )
     */
    public Object[] getValues(String name) {
        XtraTag tag = getTagByName(name);
        Object values[];
        if (tag != null) {
            values = new Object[tag.values.size()];
            for (int i = 0; i < tag.values.size(); i++) {
                values[i] = tag.values.elementAt(i).getValueAsObject();
            }
        } else {
            values = new Object[0];
        }
        return values;
    }


    /**
     * Removes specified tag (all values for that tag will be removed)
     *
     * @param name Tag to remove
     */
    public void removeTag(String name) {
        XtraTag tag = getTagByName(name);
        if (tag != null) {
            tags.remove(tag);
        }
    }

    /**
     * Removes and recreates tag using specified String values
     *
     * @param name   Tag name to replace
     * @param values New String values
     */
    public void setTagValues(String name, String values[]) {
        removeTag(name);
        XtraTag tag = new XtraTag(name);
        for (int i = 0; i < values.length; i++) {
            tag.values.addElement(new XtraValue(values[i]));
        }
        tags.addElement(tag);
    }


    /**
     * Removes and recreates tag using specified String value
     *
     * @param name  Tag name to replace
     * @param value New String value
     */
    public void setTagValue(String name, String value) {
        setTagValues(name, new String[]{value});
    }


    /**
     * Removes and recreates tag using specified Date value
     *
     * @param name Tag name to replace
     * @param date New Date value
     */
    public void setTagValue(String name, Date date) {
        removeTag(name);
        XtraTag tag = new XtraTag(name);
        tag.values.addElement(new XtraValue(date));
        tags.addElement(tag);
    }

    /**
     * Removes and recreates tag using specified Long value
     *
     * @param name  Tag name to replace
     * @param value New Long value
     */
    public void setTagValue(String name, long value) {
        removeTag(name);
        XtraTag tag = new XtraTag(name);
        tag.values.addElement(new XtraValue(value));
        tags.addElement(tag);
    }

    private XtraTag getTagByName(String name) {
        for (XtraTag tag : tags) {
            if (tag.tagName.equals(name)) {
                return tag;
            }
        }
        return null;
    }

    private static class XtraTag {
        private int inputSize; //For debugging only

        private String tagName;
        private Vector<XtraValue> values;

        private XtraTag() {
            values = new Vector<XtraValue>();
        }

        private XtraTag(String name) {
            this();
            tagName = name;
        }

        private void parse(ByteBuffer content) {
            inputSize = content.getInt();
            int tagLength = content.getInt();
            tagName = readAsciiString(content, tagLength);
            int count = content.getInt();

            for (int i = 0; i < count; i++) {
                XtraValue val = new XtraValue();
                val.parse(content);
                values.addElement(val);
            }
            if (inputSize != getContentSize()) {
                throw new RuntimeException("Improperly handled Xtra tag: Sizes don't match ( " + inputSize + "/" + getContentSize() + ") on " + tagName);
            }
        }

        private void getContent(ByteBuffer b) {
            b.putInt(getContentSize());
            b.putInt(tagName.length());
            writeAsciiString(b, tagName);
            b.putInt(values.size());
            for (int i = 0; i < values.size(); i++) {
                values.elementAt(i).getContent(b);
            }
        }

        private int getContentSize() {
            //Size: 4
            //TagLength: 4
            //Tag: tagLength;
            //Count: 4
            //Values: count * values.getContentSize();
            int size = 12 + tagName.length();
            for (int i = 0; i < values.size(); i++) {
                size += values.elementAt(i).getContentSize();
            }
            return size;
        }

        public String toString() {
            StringBuffer b = new StringBuffer();
            b.append(tagName);
            b.append(" [");
            b.append(inputSize);
            b.append("/");
            b.append(values.size());
            b.append("]:\n");
            for (int i = 0; i < values.size(); i++) {
                b.append("  ");
                b.append(values.elementAt(i).toString());
                b.append("\n");
            }
            return b.toString();
        }

    }


    private static class XtraValue {
        public int type;

        public String stringValue;
        public long longValue;
        public byte[] nonParsedValue;
        public Date fileTimeValue;

        private XtraValue() {

        }

        private XtraValue(String val) {
            type = MP4_XTRA_BT_UNICODE;
            stringValue = val;
        }

        private XtraValue(long longVal) {
            type = MP4_XTRA_BT_INT64;
            longValue = longVal;
        }

        private XtraValue(Date time) {
            type = MP4_XTRA_BT_FILETIME;
            fileTimeValue = time;
        }

        private Object getValueAsObject() {
            switch (type) {
                case MP4_XTRA_BT_UNICODE:
                    return stringValue;
                case MP4_XTRA_BT_INT64:
                    return new Long(longValue);
                case MP4_XTRA_BT_FILETIME:
                    return fileTimeValue;
                case MP4_XTRA_BT_GUID:
                default:
                    return nonParsedValue;
            }
        }

        private void parse(ByteBuffer content) {
            int length = content.getInt() - 6; //length + type are included in length
            type = content.getShort();
            content.order(ByteOrder.LITTLE_ENDIAN);
            switch (type) {
                case MP4_XTRA_BT_UNICODE:
                    stringValue = readUtf16String(content, length);
                    break;
                case MP4_XTRA_BT_INT64:
                    longValue = content.getLong();
                    break;
                case MP4_XTRA_BT_FILETIME:
                    fileTimeValue = new Date(filetimeToMillis(content.getLong()));
                    break;
                case MP4_XTRA_BT_GUID:
                default:
                    nonParsedValue = new byte[length];
                    content.get(nonParsedValue);
                    break;

            }
            content.order(ByteOrder.BIG_ENDIAN);

        }

        private void getContent(ByteBuffer b) {
            try {
                int length = getContentSize();
                b.putInt(length);
                b.putShort((short) type);
                b.order(ByteOrder.LITTLE_ENDIAN);
                switch (type) {
                    case MP4_XTRA_BT_UNICODE:
                        writeUtf16String(b, stringValue);
                        break;
                    case MP4_XTRA_BT_INT64:
                        b.putLong(longValue);
                        break;
                    case MP4_XTRA_BT_FILETIME:
                        b.putLong(millisToFiletime(fileTimeValue.getTime()));
                        break;
                    case MP4_XTRA_BT_GUID:
                    default:
                        b.put(nonParsedValue);
                        break;
                }
            } finally {
                b.order(ByteOrder.BIG_ENDIAN);
            }
        }

        private int getContentSize() {
            //Length: 4 bytes
            //Type: 2 bytes
            //Content: length bytes
            int size = 6;

            switch (type) {
                case MP4_XTRA_BT_UNICODE:
                    size += (stringValue.length() * 2) + 2; //Plus 2 for trailing null
                    break;
                case MP4_XTRA_BT_INT64:
                case MP4_XTRA_BT_FILETIME:
                    size += 8;
                    break;
                case MP4_XTRA_BT_GUID:
                default:
                    size += nonParsedValue.length;
                    break;
            }
            return size;
        }

        public String toString() {
            switch (type) {
                case MP4_XTRA_BT_UNICODE:
                    return "[string]" + stringValue;
                case MP4_XTRA_BT_INT64:
                    return "[long]" + String.valueOf(longValue);
                case MP4_XTRA_BT_FILETIME:
                    return "[filetime]" + fileTimeValue.toString();
                case MP4_XTRA_BT_GUID:
                default:
                    return "[GUID](nonParsed)";

            }
        }

    }


    //http://stackoverflow.com/questions/5398557/java-library-for-dealing-with-win32-filetime
    private static final long FILETIME_EPOCH_DIFF = 11644473600000L;
    private static final long FILETIME_ONE_MILLISECOND = 10 * 1000;

    private static long filetimeToMillis(final long filetime) {
        return (filetime / FILETIME_ONE_MILLISECOND) - FILETIME_EPOCH_DIFF;
    }

    private static long millisToFiletime(final long millis) {
        return (millis + FILETIME_EPOCH_DIFF) * FILETIME_ONE_MILLISECOND;
    }

    private static void writeAsciiString(ByteBuffer dest, String s) {
        try {
            dest.put(s.getBytes("US-ASCII"));
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("Shouldn't happen", e);
        }
    }

    private static String readAsciiString(ByteBuffer content, int length) {
        byte s[] = new byte[length];
        content.get(s);
        try {
            return new String(s, "US-ASCII");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("Shouldn't happen", e);
        }
    }


    private static String readUtf16String(ByteBuffer content, int length) {
        char s[] = new char[(length / 2) - 1];
        for (int i = 0; i < (length / 2) - 1; i++) {
            s[i] = content.getChar();
        }
        content.getChar(); //Discard terminating null
        return new String(s);
    }

    private static void writeUtf16String(ByteBuffer dest, String s) {
        char ar[] = s.toCharArray();
        for (int i = 0; i < ar.length; i++) { //Probably not the best way to do this but it preserves the byte order
            dest.putChar(ar[i]);
        }
        dest.putChar((char) 0); //Terminating null
    }

}
