/*
 * Copyright (c) 2008-2011, Oracle and/or its affiliates. All rights reserved.
 */

package javafx.scene.media;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.FileNotFoundException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.scene.image.Image;
import javafx.util.Duration;

import com.sun.media.jfxmedia.locator.Locator;
import javafx.beans.property.ReadOnlyIntegerProperty;
import javafx.beans.property.ReadOnlyIntegerWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import com.sun.media.jfxmedia.metadata.MetadataParser;
import com.sun.media.jfxmedia.events.MetadataListener;

/**
 * The <code>Media</code> class represents a media resource. It is instantiated
 * from the string form of a source URI. Information about the media such as
 * duration, metadata, tracks, and video resolution may be obtained from a
 * <code>Media</code> instance. The media information is obtained asynchronously
 * and so not necessarily available immediately after instantiation of the class.
 * All information should however be available if the instance has been
 * associated with a {@link MediaPlayer} and that player has transitioned to
 * {@link MediaPlayer.Status#READY} status. To be notified when metadata or
 * {@link Track}s are added, observers may be registered with the collections
 * returned by {@link #getMetadata()}and {@link #getTracks()}, respectively.</p>
 *
 * <p>The same <code>Media</code> object may be shared among multiple
 * <code>MediaPlayer</code> objects. Such a shared instance might manage a single
 * copy of the source media data to be used by all players, or it might require a
 * separate copy of the data for each player. The choice of implementation will
 * not however have any effect on player behavior at the interface level.</p>
 *
 * @see MediaPlayer
 * @see MediaException
 */
public final class Media {
    /**
     * A property set to a MediaException value when an error occurs.
     * If <code>error</code> is non-<code>null</code>, then the media could not
     * be loaded and is not usable. If {@link #onErrorProperty onError} is non-<code>null</code>,
     * it will be invoked when the <code>error</code> property is set.
     *
     * @see MediaException
     */
    private ReadOnlyObjectWrapper<MediaException> error;

    private void setError(MediaException value) {
        errorPropertyImpl().set(value);
    }

    /**
     * Return any error encountered in the media.
     * @return a {@link MediaException} or <code>null</code> if there is no error.
     */
    public final MediaException getError() {
        return error == null ? null : error.get();
    }

    public ReadOnlyObjectProperty<MediaException> errorProperty() {
        return errorPropertyImpl().getReadOnlyProperty();
    }

    private ReadOnlyObjectWrapper<MediaException> errorPropertyImpl() {
        if (error == null) {
            error = new ReadOnlyObjectWrapper<MediaException>() {

                @Override
                protected void invalidated() {
                    if (getOnError() != null) {
                        Platform.runLater(getOnError());
                    }
                }

                @Override
                public Object getBean() {
                    return Media.this;
                }

                @Override
                public String getName() {
                    return "error";
                }
            };
        }
        return error;
    }
    /**
     * Event handler called when an error occurs. This will happen
     * if a malformed or invalid URL is passed to the constructor or there is
     * a problem accessing the URL.
     */
    private ObjectProperty<Runnable> onError;

    /**
     * Set the event handler to be called when an error occurs.
     * @param value the error event handler.
     */
    public final void setOnError(Runnable value) {
        onErrorProperty().set(value);
    }

    /**
     * Retrieve the error handler to be called if an error occurs.
     * @return the error handler or <code>null</code> if none is defined.
     */
    public final Runnable getOnError() {
        return onError == null ? null : onError.get();
    }

    public ObjectProperty<Runnable> onErrorProperty() {
        if (onError == null) {
            onError = new ObjectPropertyBase<Runnable>() {

                @Override
                protected void invalidated() {
                    /*
                     * if we have an existing error condition schedule the handler to be
                     * called immediately. This way the client app does not have to perform
                     * an explicit error check.
                     */
                    if (get() != null && getError() != null) {
                        Platform.runLater(get());
                    }
                }

                @Override
                public Object getBean() {
                    return Media.this;
                }

                @Override
                public String getName() {
                    return "onError";
                }
            };
        }
        return onError;
    }

    private MetadataListener metadataListener = new _MetadataListener();

    /**
     * An {@link ObservableMap} of metadata which can contain information about
     * the media. Metadata entries use <@link String}s for keys and contain
     * {@link Object} values. This map is unmodifiable: its contents or stored
     * values cannot be changed.
     */
    // FIXME: define standard metadata keys and the corresponding objects types
    // FIXME: figure out how to make the entries read-only to observers, we'll
    //        need to enhance javafx.collections a bit to accomodate this
    private ObservableMap<String, Object> metadata;

    /**
     * Retrieve the metadata contained in this media source. If there are
     * no metadata, the returned {@link ObservableMap} will be empty.
     * @return the metadata contained in this media source.
     */
    public final ObservableMap<String, Object> getMetadata() {
        return metadata;
    }

    private final ObservableMap<String,Object> metadataBacking;
    /**
     * The width in pixels of the source media.
     * This may be zero if the media has no width, e.g., when playing audio,
     * or if the width is currently unknown which may occur with streaming
     * media.
     * @see height
     */
    private ReadOnlyIntegerWrapper width;


    final void setWidth(int value) {
        widthPropertyImpl().set(value);
    }

    /**
     * Retrieve the width in pixels of the media.
     * @return the media width or zero if the width is undefined or unknown.
     */
    public final int getWidth() {
        return width == null ? 0 : width.get();
    }

    public ReadOnlyIntegerProperty widthProperty() {
        return widthPropertyImpl().getReadOnlyProperty();
    }

    private ReadOnlyIntegerWrapper widthPropertyImpl() {
        if (width == null) {
            width = new ReadOnlyIntegerWrapper(this, "width");
        }
        return width;
    }
    /**
     * The height in pixels of the source media.
     * This may be zero if the media has no height, e.g., when playing audio,
     * or if the height is currently unknown which may occur with streaming
     * media.
     * @see width
     */
    private ReadOnlyIntegerWrapper height;


    final void setHeight(int value) {
        heightPropertyImpl().set(value);
    }

    /**
     * Retrieve the height in pixels of the media.
     * @return the media height or zero if the height is undefined or unknown.
     */
    public final int getHeight() {
        return height == null ? 0 : height.get();
    }

    public ReadOnlyIntegerProperty heightProperty() {
        return heightPropertyImpl().getReadOnlyProperty();
    }

    private ReadOnlyIntegerWrapper heightPropertyImpl() {
        if (height == null) {
            height = new ReadOnlyIntegerWrapper(this, "height");
        }
        return height;
    }
    /**
     * The duration in seconds of the source media. If the media duration is
     * unknown then this property value will be {@link Duration#UNKNOWN}.
     */
    private ReadOnlyObjectWrapper<Duration> duration;

    final void setDuration(Duration value) {
        durationPropertyImpl().set(value);
    }

    /**
     * Retrieve the duration in seconds of the media.
     * @return the duration of the media or {@link Duration#UNKNOWN} if unknown.
     */
    public final Duration getDuration() {
        return duration == null ? Duration.UNKNOWN : duration.get();
    }

    public ReadOnlyObjectProperty<Duration> durationProperty() {
        return durationPropertyImpl().getReadOnlyProperty();
    }

    private ReadOnlyObjectWrapper<Duration> durationPropertyImpl() {
        if (duration == null) {
            duration = new ReadOnlyObjectWrapper<Duration>(this, "duration");
        }
        return duration;
    }
    /**
     * An <code>ObservableList</code> of tracks contained in this media object.
     * A <code>Media</code> object can contain multiple tracks, such as a video track
     * with several audio track. This list is unmodifiable: the contents cannot
     * be changed.
     * @see Track
     */
    private ObservableList<Track> tracks;

    /**
     * Retrieve the tracks contained in this media source. If there are
     * no tracks, the returned {@link ObservableList} will be empty.
     * @return the tracks contained in this media source.
     */
    public final ObservableList<Track> getTracks() {
        return tracks;
    }
    private final ObservableList<Track> tracksBacking;

    /**
     * The markers defined on this media source. A marker is defined to be a
     * mapping from a name to a point in time between the beginning and end of
     * the media.
     */
    private ObservableMap<String, Duration> markers;

    /**
     * Retrieve the markers defined on this <code>Media</code> instance. If
     * there are no markers the returned {@link ObservableMap} will be empty.
     * Programmatic markers may be added by inserting entries in the returned
     * <code>Map</code>.
     *
     * @return the markers defined on this media source.
     */
    public final ObservableMap<String, Duration> getMarkers() {
        return markers;
    }

    /**
     * Constructs a <code>Media</code> instance.  This is the only way to
     * specify the media source. The source must represent a valid <code>URI</code>
     * and is immutable. Only HTTP, FILE, and JAR <code>URL</code>s are supported. If the
     * provided URL is invalid then an exception will be thrown.  If an
     * asynchronous error occurs, the {@link #errorProperty error} property will be set. Listen
     * to this property to be notified of any such errors.
     *
     * <p>If the source uses a non-blocking protocol such as FILE, then any
     * problems which can be detected immediately will cause a <code>MediaException</code>
     * to be thrown. Such problems include the media being inaccessible or in an
     * unsupported format. If however a potentially blocking protocol such as
     * HTTP is used, then the connection will be initialized asynchronously so
     * that these sorts of errors will be signaled by setting the {@link #errorProperty error}
     * property.</p>
     *
     * <p>Constraints:
     * <ul>
     * <li>The supplied URI must conform to RFC-2396 as required by
     * <A href="http://download.oracle.com/javase/6/docs/api/java/net/URI.html">java.net.URI</A>.</li>
     * <li>Only HTTP, FILE, and JAR URIs are supported.</li>
     * </ul>
     *
     * <p>See <A href="http://download.oracle.com/javase/6/docs/api/java/net/URI.html">java.net.URI</A>
     * for more information about URI formatting in general.
     * JAR URL syntax is specified in <a href="http://download.oracle.com/javase/6/docs/api/java/net/JarURLConnection.html">java.net.JarURLConnection</A>.
     *
     * @param source The URI of the source media.
     * @throws NullPointerException if the URI string is <code>null</code>.
     * @throws IllegalArgumentException if the URI string does not conform to RFC-2396
     * or, if appropriate, the Jar URL specification, or is in a non-compliant
     * form which cannot be modified to a compliant form.
     * @throws IllegalArgumentException if the URI string has a <code>null</code>
     * scheme.
     * @throws UnsupportedOperationException if the protocol specified for the
     * source is not supported.
     * @throws MediaException if the media source cannot be connected
     * (type {@link MediaException.Type#MEDIA_INACCESSIBLE}) or is not supported
     * (type {@link MediaException.Type#MEDIA_UNSUPPORTED}).
     */
    public Media(String source) {
        URI uri = null;
        try {
            // URI will throw NPE if source == null: do not catch it!
            uri = new URI(source);
        } catch(URISyntaxException use) {
            throw new IllegalArgumentException(use);
        }

        Locator locator = null;
        try {
            locator = new com.sun.media.jfxmedia.locator.Locator(uri);
            jfxLocator = locator;
            if (locator.canBlock()) {
                InitLocator locatorInit = new InitLocator();
                Thread t = new Thread(locatorInit);
                t.setDaemon(true);
                t.start();
            } else {
                locator.init();
                runMetadataParser();
            }
        } catch(URISyntaxException use) {
            throw new IllegalArgumentException(use);
        } catch(FileNotFoundException fnfe) {
            throw new MediaException(MediaException.Type.MEDIA_UNAVAILABLE, fnfe.getMessage());
        } catch(IOException ioe) {
            throw new MediaException(MediaException.Type.MEDIA_INACCESSIBLE, ioe.getMessage());
        } catch(com.sun.media.jfxmedia.MediaException me) {
            throw new MediaException(MediaException.Type.MEDIA_UNSUPPORTED, me.getMessage());
        }

        this.source = source;

        metadataBacking = FXCollections.observableMap(new HashMap<String,Object>());
        metadata = FXCollections.unmodifiableObservableMap(metadataBacking);

        tracksBacking = FXCollections.observableArrayList();
        tracks = FXCollections.unmodifiableObservableList(tracksBacking);

        markers = FXCollections.observableMap(new HashMap<String,Duration>());
    }

    private void runMetadataParser() {
        // Create and start metadata parser
        MetadataParser parser = null;
        try {
            parser = MetadataParser.createMetadataParser(jfxLocator);
            parser.addListener(metadataListener);
            parser.startParser();
        } catch (Exception e) {
            // Ignore it
        }
        this.jfxParser = parser;
    }

    /**
     * The source URI of the media;
     */
    private final String source;

    /**
     * Retrieve the source URI of the media.
     * @return the media source URI as a {@link String}.
     */
    public String getSource() {
        return source;
    }

    /**
     * Locator used by the jfxmedia player, MediaPlayer needs access to this
     */
    private final Locator jfxLocator;
    Locator retrieveJfxLocator() {
        return jfxLocator;
    }

    private MetadataParser jfxParser;

    void _updateMedia(com.sun.media.jfxmedia.Media _media) {
        try {
            List<com.sun.media.jfxmedia.track.Track> trackList = _media.getTracks();

            if (trackList != null) {
                Set<Track> newTracks = new HashSet<Track>();

                for (com.sun.media.jfxmedia.track.Track trackElement : trackList) {
                    if (trackElement instanceof com.sun.media.jfxmedia.track.VideoTrack) {
                        com.sun.media.jfxmedia.track.VideoTrack vt =
                                (com.sun.media.jfxmedia.track.VideoTrack) trackElement;
                        setWidth(vt.getFrameSize().getWidth());
                        setHeight(vt.getFrameSize().getHeight());
                        VideoTrack videoTrack = new VideoTrack(vt);
                        newTracks.add(videoTrack);
                    } else if (trackElement instanceof com.sun.media.jfxmedia.track.AudioTrack) {
                        com.sun.media.jfxmedia.track.AudioTrack at =
                                (com.sun.media.jfxmedia.track.AudioTrack) trackElement;
                        AudioTrack audioTrack = new AudioTrack(at);
                        newTracks.add(audioTrack);
                    }

                    /*
                     * Just in case this gets called multiple times we don't want to
                     * accumulate tracks so call setAll to replace the entire observableArrayList
                     *
                     * FIXME: We should really check the current list and only add new tracks
                     */
                    tracksBacking.setAll(newTracks);
                }
            }
        } catch (Exception e) {
            // Save any async exceptions as an error.
            setError(new MediaException(MediaException.Type.UNKNOWN, e));
        }
    }

    void _setError(MediaException.Type type, String message) {
        setError(new MediaException(type, message));
    }

    private synchronized void updateMetadata(Map<String, Object> metadata) {
        if (metadata != null) {
            for (String key : metadata.keySet()) {
                Object value = metadata.get(key);
                if (key.equals(MetadataParser.IMAGE_TAG_NAME) && value instanceof byte[]) {
                    byte[] imageData = (byte[]) value;
                    Image image = new Image(new ByteArrayInputStream(imageData));
                    if (!image.isError()) {
                        metadataBacking.put(MetadataParser.IMAGE_TAG_NAME, image);
                    }
                } else if (key.equals(MetadataParser.DURATION_TAG_NAME) && value instanceof java.lang.Long) {
                    Duration d = new Duration((Long) value);
                    if (d != null) {
                        metadataBacking.put(MetadataParser.DURATION_TAG_NAME, d);
                    }
                } else {
                    metadataBacking.put(key, value);
                }
            }
        }
    }

    private class _MetadataListener implements MetadataListener {
        @Override
        public void onMetadata(final Map<String, Object> metadata) {
            // Clean up metadata
            Platform.runLater(new Runnable() {
                @Override
                public void run() {
                    updateMetadata(metadata);
                    jfxParser.removeListener(metadataListener);
                    jfxParser.stopParser();
                    jfxParser = null;
                }
            });
        }
    }

    private class InitLocator implements Runnable {

        @Override
        public void run() {
            try {
                jfxLocator.init();
                runMetadataParser();
            } catch (URISyntaxException use) {
                _setError(MediaException.Type.OPERATION_UNSUPPORTED, use.getMessage());
            } catch (FileNotFoundException fnfe) {
                _setError(MediaException.Type.MEDIA_UNAVAILABLE, fnfe.getMessage());
            } catch (IOException ioe) {
                _setError(MediaException.Type.MEDIA_INACCESSIBLE, ioe.getMessage());
            } catch (com.sun.media.jfxmedia.MediaException me) {
                _setError(MediaException.Type.MEDIA_UNSUPPORTED, me.getMessage());
            } catch (Exception e) {
                _setError(MediaException.Type.UNKNOWN, e.getMessage());
            }
        }
    }
}
