package org.forkalsrud.album.video;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

import org.forkalsrud.album.exif.Dimension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class FlvMetadata {
	
	private static Logger log = LoggerFactory.getLogger(FlvMetadata.class);

    private abstract class Attr<T> {

        protected boolean present = false;
        protected String name;
        protected T value;

        public Attr(String name) {
            super();
            this.name = name;
        }

        public void set(T value) {
            this.present = true;
            this.value = value;
        }

        @SuppressWarnings("unchecked")
		public void merge(Attr<?> other) {
        	if (other != null && other.getClass() == getClass()) {
        		set((T)other.value);
        	}
        }

        public abstract void write(OutputStream out) throws IOException;

        public boolean isPresent() {
            return present;
        }

        public abstract void read(InputStream in) throws FlvFormatException, IOException;
    }

    private class StringAttr extends Attr<String> {

        public StringAttr(String name) {
            super(name);
        }

        @Override
        public void write(OutputStream out) throws IOException {
            out.write(2);   // DataString
            writeFlvString(out, value);
        }

        @Override
        public void read(InputStream in) throws FlvFormatException, IOException {
            int type = in.read();
            if (type != 2) {
                throw new FlvFormatException("Not a string: " + type);
            }
            set(readString(in));
        }
    }

    private class BooleanAttr extends Attr<Boolean> {

        public BooleanAttr(String name) {
            super(name);
        }

        @Override
        public void write(OutputStream out) throws IOException {
            out.write(1);   // Bool
            out.write(value ? 1 : 0);
        }

        @Override
        public void read(InputStream in) throws FlvFormatException, IOException {
            int type = in.read();
            if (type != 1) {
                throw new FlvFormatException("Not a boolean: " + type);
            }
            set(readBoolean(in));
        }
    }

    private class DoubleAttr extends Attr<Double> {

        public DoubleAttr(String name) {
            super(name);
        }

        @Override
        public void write(OutputStream out) throws IOException {
            out.write(0);   // NUMBER
            writeDouble(out, value);
        }

        @Override
        public void read(InputStream in) throws FlvFormatException, IOException {
            int type = in.read();
            if (type != 0) {
                throw new FlvFormatException("Not a number: " + type);
            }
            set(readDouble(in));
        }
    }

    private class FileOffsetAttr extends Attr<Integer> {

        public FileOffsetAttr(String name) {
            super(name);
        }

        @Override
        public void write(OutputStream out) throws IOException {
            out.write(0);   // NUMBER
            writeDouble(out, value + filePositionDelta);
        }

        @Override
        public void read(InputStream in) throws FlvFormatException, IOException {
            int type = in.read();
            if (type != 0) {
                throw new FlvFormatException("Not a boolean: " + type);
            }
            set((int)readDouble(in) - filePositionDelta);
        }
    }

    private class KeyframesAttr extends Attr<List<Keyframe>> {

        public KeyframesAttr(String name, List<Keyframe> value) {
            super(name);
            this.value = value;
        }

        
        @Override
        public void write(OutputStream out) throws IOException {

            FileOffsetAttr offset = new FileOffsetAttr(null);
            DoubleAttr timestamp = new DoubleAttr(null);

            out.write(3);    // Variable Array

            writeFlvValueArray(out, "filepositions", value.size());
            for (Keyframe f : value) {
                offset.set(f.getFileOffset());
                offset.write(out);
            }
            writeFlvValueArray(out, "times", value.size());

            for (Keyframe f : value) {
                timestamp.set(f.getTimestamp() / 1000.0);
                timestamp.write(out);
            }
            writeFlvVariableArrayEnd(out);
        }

        @Override
        public void read(InputStream in) throws FlvFormatException, IOException {

            FileOffsetAttr offset = new FileOffsetAttr(null);
            DoubleAttr timestamp = new DoubleAttr(null);

            int type = in.read();
            if (type != 3) {
                throw new FlvFormatException("Not an object: " + type);
            }
            if (!in.markSupported()) {
                throw new FlvFormatException("Need readahead");
            }
            List<Integer> filePositions = null;
            List<Double> times = null;
            while (!isLookingAtEnd(in)) {
                String propName = readString(in);
                if ("filepositions".equals(propName)) {
                    filePositions = readValueArray(in, offset);
                } else if ("times".equals(propName)) {
                    times = readValueArray(in, timestamp);
                }
            }
            readObjectEnd(in);
            if (times != null && filePositions != null) {
                if (times.size() != filePositions.size()) {
                    throw new FlvFormatException("positions and times need to match up " + times.size() + " v.s. " + filePositions.size());
                }
                keyframes.clear();
                for (int i = 0; i < times.size(); i++) {
                    addVideoFrame(filePositions.get(i), Math.round(times.get(i).floatValue()), true);
                }
            } else {
                System.out.println("Missing filepositions (" + filePositions + ") or times (" + times + ")");
            }
        }

        @Override
        public boolean isPresent() {
            return !value.isEmpty();
        }
    }

    private int filePositionDelta = 0;

    private StringAttr creator = new StringAttr("creator");
    private StringAttr metadataCreator = new StringAttr("metadatacreator");
    private StringAttr majorBrand = new StringAttr("major_brand");
    private StringAttr minorVersion = new StringAttr("minor_version");
    private StringAttr compatibleBrands = new StringAttr("compatible_brands");
    private StringAttr creationTime = new StringAttr("creation_time");
    private StringAttr encoder = new StringAttr("encoder");
    private StringAttr encoderEng = new StringAttr( "encoder-eng");
    private StringAttr date = new StringAttr("date");
    private StringAttr dateEng = new StringAttr("date-eng");

    private BooleanAttr hasKeyframes = new BooleanAttr("hasKeyframes");
    private BooleanAttr hasVideo = new BooleanAttr("hasVideo");
    private BooleanAttr hasAudio = new BooleanAttr("hasAudio");
    private BooleanAttr hasMetadata = new BooleanAttr("hasMetadata");
    private BooleanAttr lastFrameIsKeyframe = new BooleanAttr("canSeekToEnd");

    private DoubleAttr duration = new DoubleAttr("duration");
    private DoubleAttr datasize = new DoubleAttr("datasize");

    private DoubleAttr videosize = new DoubleAttr("videosize");
    private DoubleAttr framerate = new DoubleAttr("framerate");
    private DoubleAttr videodatarate = new DoubleAttr("videodatarate");

    private DoubleAttr videocodecid = new DoubleAttr("videocodecid");
    private DoubleAttr width = new DoubleAttr("width");
    private DoubleAttr height = new DoubleAttr("height");

    private DoubleAttr audiosize = new DoubleAttr("audiosize");
    private DoubleAttr audiodatarate = new DoubleAttr("audiodatarate");
    private DoubleAttr audiodelay = new DoubleAttr("audiodelay");

    private DoubleAttr audiocodecid = new DoubleAttr("audiocodecid");
    private DoubleAttr audiosamplerate = new DoubleAttr("audiosamplerate");
    private DoubleAttr audiosamplesize = new DoubleAttr("audiosamplesize");
    private BooleanAttr stereo = new BooleanAttr("stereo");

    private DoubleAttr filesize = new DoubleAttr("filesize");
    private DoubleAttr lasttimestamp = new DoubleAttr("lasttimestamp");

    private DoubleAttr lastkeyframetimestamp = new DoubleAttr("lastkeyframetimestamp");
    private DoubleAttr lastkeyframelocation = new DoubleAttr("lastkeyframelocation");

    private LinkedList<Keyframe> keyframes = new LinkedList<Keyframe>();

    private ArrayList<Attr<?>> attrs = new ArrayList<Attr<?>>();

    public FlvMetadata() {

        metadataCreator.set("forkalsrud.org");
        hasMetadata.set(true);

        attrs.add(creator);
        attrs.add(metadataCreator);
        attrs.add(majorBrand);
        attrs.add(minorVersion);
        attrs.add(compatibleBrands);
        attrs.add(creationTime);
        attrs.add(date);
        attrs.add(dateEng);
        attrs.add(encoder);
        attrs.add(encoderEng);

        attrs.add(hasKeyframes);
        attrs.add(hasVideo);
        attrs.add(hasAudio);
        attrs.add(hasMetadata);
        attrs.add(lastFrameIsKeyframe);
        attrs.add(duration);
        attrs.add(datasize);

        attrs.add(videosize);
        attrs.add(framerate);
        attrs.add(videodatarate);

        attrs.add(videocodecid);
        attrs.add(width);
        attrs.add(height);

        attrs.add(audiosize);
        attrs.add(audiodatarate);
        attrs.add(audiodelay);

        attrs.add(audiocodecid);
        attrs.add(audiosamplerate);
        attrs.add(audiosamplesize);
        attrs.add(stereo);

        attrs.add(filesize);
        attrs.add(lasttimestamp);

        attrs.add(lastkeyframetimestamp);
        attrs.add(lastkeyframelocation);

        attrs.add(new KeyframesAttr("keyframes", keyframes));
    }


    private int length() {
        int i = 0;
        for (Attr<?> a : attrs) {
            if (a.isPresent()) {
                i++;
            }
        }
        return i;
    }

    public void clearKeyframes() {
        keyframes.clear();
    }

    public void addVideoFrame(int filePos, int timestamp, boolean isKeyframe) {

        hasVideo.set(true);
        if (isKeyframe) {
            keyframes.add(new Keyframe(filePos, timestamp));
            hasKeyframes.set(true);
            lastkeyframelocation.set((double)filePos);
            lastkeyframetimestamp.set((double)timestamp);
        }
        lasttimestamp.set((double)timestamp);
        lastFrameIsKeyframe.set(isKeyframe);
    }

    public void addAudioFrame(int filePos, int timestamp) {

        hasAudio.set(true);
    }

    public int calculateLength() throws IOException {

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        writeScriptDataObject(baos);
        return baos.size();
    }

    public void setFileOffsetDelta(int fileOffsetDelta) {

        this.filePositionDelta = fileOffsetDelta;
    }

    /**
     * @param out
     * @throws IOException
     */
    public void writeScriptDataObject(OutputStream out) throws IOException {
        // ScriptDataObject
        out.write(2);  // string
        writeFlvString(out, "onMetaData");
        writeFlvEcmaArray(out, length());
        for (Attr<?> a : attrs) {
            if (a.isPresent()) {
                if (a.name != null) {
                    writeFlvString(out, a.name);
                }
                a.write(out);
            }
        }
        writeFlvVariableArrayEnd(out);
    }

    void writeDouble(OutputStream out, double value) throws IOException {

        long x = Double.doubleToLongBits(value);
        writeUint64(out, x);
    }

    void writeFlvString(OutputStream out, String s) throws IOException {

        byte[] bytes = s.getBytes();
        writeUint16(out, bytes.length);
        out.write(bytes);
    }

    private void writeFlvEcmaArray(OutputStream out, int len) throws IOException {
        out.write(8);    // ECMAArray
        writeUint32(out, len);
    }

    private void writeFlvVariableArrayEnd(OutputStream out) throws IOException {
        out.write(0);
        out.write(0);
        out.write(9);
    }

    private void writeFlvValueArray(OutputStream out, String name, int len) throws IOException {

        writeFlvString(out, name);
        out.write(10);    // Value Array
        writeUint32(out, len);
    }

    void writeUint64(OutputStream out, long value) throws IOException {

        out.write((int) ((value >> 56) & 0xff));
        out.write((int) ((value >> 48) & 0xff));
        out.write((int) ((value >> 40) & 0xff));
        out.write((int) ((value >> 32) & 0xff));
        out.write((int) ((value >> 24) & 0xff));
        out.write((int) ((value >> 16) & 0xff));
        out.write((int) ((value >>  8) & 0xff));
        out.write((int) ((value >>  0) & 0xff));
    }

    void writeUint32(OutputStream out, int value) throws IOException {

        out.write((value >> 24) & 0xff);
        out.write((value >> 16) & 0xff);
        out.write((value >>  8) & 0xff);
        out.write((value >>  0) & 0xff);
    }

    void writeUint24(OutputStream out, int value) throws IOException {

        out.write((value >> 16) & 0xff);
        out.write((value >>  8) & 0xff);
        out.write((value >>  0) & 0xff);
    }

    void writeUint16(OutputStream out, int value) throws IOException {

        out.write((value >> 8) & 0xff);
        out.write((value >> 0) & 0xff);
    }

    void writeTimestamp(OutputStream out, int value) throws IOException {

        out.write((value >> 16) & 0xff);
        out.write((value >>  8) & 0xff);
        out.write((value >>  0) & 0xff);
        out.write((value >> 24) & 0xff);
    }

    private static class Keyframe {

        private int fileOffset;
        private int timestamp;

        public Keyframe(int fileOffset, int timestamp) {
            super();
            this.fileOffset = fileOffset;
            this.timestamp = timestamp;
        }

        public int getFileOffset() {
            return fileOffset;
        }
        public int getTimestamp() {
            return timestamp;
        }
    }

    public void setDuration(String durationStr) {
    	if (durationStr != null && durationStr.contains(":")) {
    		int ps = durationStr.lastIndexOf(':');
    		double seconds = Double.parseDouble(durationStr.substring(ps + 1));
    		int pm = durationStr.lastIndexOf(':', ps - 1);
    		if (pm >= 0) {
    			seconds += 60 * Double.parseDouble(durationStr.substring(pm + 1, ps));
    			seconds += 3600 * Double.parseDouble(durationStr.substring(0, pm));
    		}
    		setDuration(seconds);
    	} else if (durationStr != null) {
    		setDuration(Double.parseDouble(durationStr));
    	}
    }
    
    public void setDuration(double seconds) {
        this.duration.set(Double.valueOf(seconds));
    }

    public void setDurationFromLastTimestamp() {
        if ((duration.value == null || duration.value.doubleValue() <= 0) && lasttimestamp != null && lasttimestamp.value != null) {
            setDuration(lasttimestamp.value / 1000d);
        }
    }

    public void setDimension(Dimension d) {
        this.width.set(Double.valueOf(d.getWidth()));
        this.height.set(Double.valueOf(d.getHeight()));
    }


    /**
     * Takes all the values from the parameter "theirs".
     * Corresponding values in this are overwritten.
     * Other values in this are kept.
     * The typical purpose is to fill in some missing metadata.
     * 
     * @param other
     */
    public void merge(FlvMetadata other) {

        for (int i = 0; i < attrs.size(); i++) {
            Attr<?> their = other.attrs.get(i);
            if (their.isPresent()) {
                Attr<?> our = this.attrs.get(i);
                our.merge(their);
            }
        }
    }


    public Object get(String prop) {
        if (prop == null) {
            return null;
        }
        for (Attr<?> a : attrs) {
            if (prop.equals(a.name) && a.isPresent()) {
                return a.value;
            }
        }
        return null;
    }


    public void setFileSize(int size) {
        this.filesize.set(Double.valueOf(size));
    }


    public void read(InputStream in) throws IOException {

        try {
            int type = in.read();
            if (type != 2) {
                throw new FlvFormatException("Not a string: " + type);
            }
            String name = readString(in);
            if (!"onMetaData".equalsIgnoreCase(name)) {
                throw new FlvFormatException("Not \"onMetaData\": " + name);
            }
            type = in.read();
            if (type != 8) {
                throw new FlvFormatException("Not an ECMA array: " + type);
            }
            readEcmaArrayHeader(in);
            while (!isLookingAtEnd(in)) {
                readProperty(in);
            }
            readEcmaArrayFoter(in);
        } catch (FlvFormatException e) {
            LoggerFactory.getLogger(getClass()).error("invalid", e);
        }
    }

    protected String readString(InputStream in) throws IOException {
        int len = readUint16(in);
        byte[] s = new byte[len];
        in.read(s);
        return new String(s, "UTF-8");
    }

    protected boolean readBoolean(InputStream in) throws IOException {
        int val = in.read();
        return val != 0;
    }

    protected double readDouble(InputStream in) throws IOException {
        long val = readUint64(in);
        return Double.longBitsToDouble(val);
    }

    protected long readUint64(InputStream in) throws IOException {
        long ch1 = in.read();
        long ch2 = in.read();
        long ch3 = in.read();
        long ch4 = in.read();
        long ch5 = in.read();
        long ch6 = in.read();
        long ch7 = in.read();
        long ch8 = in.read();
        return ((ch1 << 56) + (ch2 << 48) + (ch3 << 40) + (ch4 << 32) + (ch5 << 24) + (ch6 << 16) + (ch7 << 8) + (ch8 << 0));
    }

    protected int readUint32(InputStream in) throws IOException {
        int ch1 = in.read();
        int ch2 = in.read();
        int ch3 = in.read();
        int ch4 = in.read();
        return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
    }

    protected int readUint24(InputStream in) throws IOException {
        int ch1 = in.read();
        int ch2 = in.read();
        int ch3 = in.read();
        return ((ch1 << 16) + (ch2 << 8) + (ch3 << 0));
    }

    protected int readUint16(InputStream in) throws IOException {
        int ch1 = in.read();
        int ch2 = in.read();
        return ((ch1 << 8) + (ch2 << 0));
    }

    protected int readTimestamp(InputStream in) throws IOException {
        int ch1 = in.read();
        int ch2 = in.read();
        int ch3 = in.read();
        int ch4 = in.read();
        return ((ch4 << 24) + (ch1 << 16) + (ch2 << 8) + (ch3 << 0));
    }

    protected int readEcmaArrayHeader(InputStream in) throws IOException {
        int len = readUint32(in);
        return len;
    }

    protected void readEcmaArrayFoter(InputStream in) throws FlvFormatException, IOException {
        readObjectEnd(in);
    }
    
    protected void readObjectEnd(InputStream in) throws FlvFormatException, IOException {
        byte[] word = new byte[3];
        in.read(word);
        if (word[0] != 0 || word[1] != 0 || word[2] != 9) {
            throw new FlvFormatException("Not at end: " + word[0] + ", " + word[1] + ", " + word[2]);
        }
    }

    protected boolean isLookingAtEnd(InputStream in) throws IOException {
        byte[] word = new byte[3];
        in.mark(word.length);
        in.read(word);
        in.reset();
        return word[0] == 0 && word[1] == 0 && word[2] == 9;
    }

    protected void readProperty(InputStream in) throws FlvFormatException, IOException {

        String name = readString(in);
        Attr<?> attr = findAttrByName(name);
        if (attr != null) {
            attr.read(in);
        } else {
        	log.warn("Unknown metadata property: " + name);
        	readUnknownProperty(in, name);
        }
    }
    
    protected void readUnknownProperty(InputStream in, String name) throws IOException, FlvFormatException {
    	
    	in.mark(1);
        int type = in.read();
        in.reset();
        switch (type) {
		case 2:
			new StringAttr(name).read(in);
			break;
		default:
			break;
		}
    }
    
    private Attr<?> findAttrByName(String name) {
        for (Attr<?> a : this.attrs) {
            if (name.equals(a.name)) {
                return a;
            }
        }
        return null;
    }
    
    protected <T> List<T> readValueArray(InputStream in, Attr<T> elementAttr) throws FlvFormatException, IOException {
        int type = in.read();
        if (type != 10) {
            throw new FlvFormatException("Not a value array: " + type);
        }
        int len = readUint32(in);
        ArrayList<T> list = new ArrayList<T>(len);
        for (int i = 0; i < len; i++) {
            elementAttr.read(in);
            list.add(elementAttr.value);
        }
        return list;
    }


    public void setVideoCodecId(int videocodecid) {
        this.videocodecid.set(Double.valueOf(videocodecid));
    }


    public void setAudioCodecId(int audioCodecId) {
        this.audiocodecid.set(Double.valueOf(audioCodecId));
    }


    public void setFramerate(double framerate) {
        this.framerate.set(framerate);
    }



    public int getWidth() {
        return width.isPresent() ? width.value.intValue() : 0;
    }

    public int getHeight() {
        return height.isPresent() ? height.value.intValue() : 0;
    }


    @Override
    public boolean equals(Object o) {
        if (o == null || !this.getClass().equals(o.getClass())) {
            return false;
        }
        FlvMetadata other = (FlvMetadata)o;
        for (int i = 0; i < attrs.size(); i++) {
            Attr<?> their = other.attrs.get(i);
            Attr<?> our = this.attrs.get(i);
            if (our.isPresent() && their.isPresent()) {
                if (!our.value.equals(their.value)) {
                    return false;
                }
            }
            if (our.isPresent() != their.isPresent()) {
                return false;
            }
        }
        return true;
    }

    @Override
    public int hashCode() {
        int x = 0;
        for (Attr<?> a : attrs) {
            x *= 13;
            x += a.present ? a.value.hashCode() : 7;
        }
        return x;
    }

}
