/*
 * Copyright (c) 2009-2012 jMonkeyEngine
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * * Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
 *   may be used to endorse or promote products derived from this software
 *   without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.jme3.cinematic;

import com.jme3.animation.LoopMode;
import com.jme3.app.Application;
import com.jme3.app.state.AppState;
import com.jme3.app.state.AppStateManager;
import com.jme3.cinematic.events.AbstractCinematicEvent;
import com.jme3.cinematic.events.CinematicEvent;
import com.jme3.export.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.scene.CameraNode;
import com.jme3.scene.Node;
import com.jme3.scene.control.CameraControl;
import com.jme3.scene.control.CameraControl.ControlDirection;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * An appstate for composing and playing cut scenes in a game. The cineamtic
 * schedules CinematicEvents over a timeline. Once the Cinematic created it has
 * to be attched to the stateManager.
 *
 * You can add various CinematicEvents to a Cinematic, see package
 * com.jme3.cinematic.events
 *
 * Two main methods can be used to add an event :
 *
 * @see Cinematic#addCinematicEvent(float,
 * com.jme3.cinematic.events.CinematicEvent) , that adds an event at the given
 * time form the cinematic start.
 *
 * @see
 * Cinematic#enqueueCinematicEvent(com.jme3.cinematic.events.CinematicEvent)
 * that enqueue events one after the other according to their initialDuration
 *
 * a cinematic has convenient mathods to handle the playback :
 * @see Cinematic#play()
 * @see Cinematic#pause()
 * @see Cinematic#stop()
 *
 * A cinematic is itself a CinematicEvent, meaning you can embed several
 * Cinematics Embed cinematics must not be added to the stateManager though.
 *
 * Cinematic has a way to handle several point of view by creating CameraNode
 * over a cam and activating them on schedule.
 * @see Cinematic#bindCamera(java.lang.String, com.jme3.renderer.Camera)
 * @see Cinematic#activateCamera(float, java.lang.String)
 * @see Cinematic#setActiveCamera(java.lang.String)
 *
 * @author Nehon
 */
public class Cinematic extends AbstractCinematicEvent implements AppState {

    private static final Logger logger = Logger.getLogger(Application.class.getName());
    private Node scene;
    protected TimeLine timeLine = new TimeLine();
    private int lastFetchedKeyFrame = -1;
    private List<CinematicEvent> cinematicEvents = new ArrayList<CinematicEvent>();
    private Map<String, CameraNode> cameras = new HashMap<String, CameraNode>();
    private CameraNode currentCam;
    private boolean initialized = false;
    private Map<String, Map<Object, Object>> eventsData;
    private float nextEnqueue = 0;

    /**
     * Used for serialization creates a cinematic, don't use this constructor
     * directly
     */
    public Cinematic() {
    }

    /**
     * creates a cinematic
     *
     * @param scene the scene in which the cinematic should take place
     */
    public Cinematic(Node scene) {
        this.scene = scene;
    }

    /**
     * creates a cinematic
     *
     * @param scene the scene in which the cinematic should take place
     * @param initialDuration the duration of the cinematic (without considering
     * the speed)
     */
    public Cinematic(Node scene, float initialDuration) {
        super(initialDuration);
        this.scene = scene;
    }

    /**
     * creates a cinematic
     *
     * @param scene the scene in which the cinematic should take place
     * @param loopMode tells if this cinematic should be looped or not
     */
    public Cinematic(Node scene, LoopMode loopMode) {
        super(loopMode);
        this.scene = scene;
    }

    /**
     * creates a cinematic
     *
     * @param scene the scene in which the cinematic should take place
     * @param initialDuration the duration of the cinematic (without considering
     * the speed)
     * @param loopMode tells if this cinematic should be looped or not
     */
    public Cinematic(Node scene, float initialDuration, LoopMode loopMode) {
        super(initialDuration, loopMode);
        this.scene = scene;
    }

    /**
     * called internally
     */
    @Override
    public void onPlay() {
        if (isInitialized()) {
            if (playState == PlayState.Paused) {
                for (int i = 0; i < cinematicEvents.size(); i++) {
                    CinematicEvent ce = cinematicEvents.get(i);
                    if (ce.getPlayState() == PlayState.Paused) {
                        ce.play();
                    }
                }
            }
        }
    }

    /**
     * called internally
     */
    @Override
    public void onStop() {
        time = 0;
        lastFetchedKeyFrame = -1;
        for (int i = 0; i < cinematicEvents.size(); i++) {
            CinematicEvent ce = cinematicEvents.get(i);
            ce.setTime(0);
            ce.forceStop();
        }
        setEnableCurrentCam(false);
    }

    /**
     * called internally
     */
    @Override
    public void onPause() {
        for (int i = 0; i < cinematicEvents.size(); i++) {
            CinematicEvent ce = cinematicEvents.get(i);
            if (ce.getPlayState() == PlayState.Playing) {
                ce.pause();
            }
        }
    }

    /**
     * used internally for serialization
     *
     * @param ex
     * @throws IOException
     */
    @Override
    public void write(JmeExporter ex) throws IOException {
        super.write(ex);
        OutputCapsule oc = ex.getCapsule(this);

        oc.writeSavableArrayList((ArrayList) cinematicEvents, "cinematicEvents", null);
        oc.writeStringSavableMap(cameras, "cameras", null);
        oc.write(timeLine, "timeLine", null);

    }

    /**
     * used internally for srialization
     *
     * @param im
     * @throws IOException
     */
    @Override
    public void read(JmeImporter im) throws IOException {
        super.read(im);
        InputCapsule ic = im.getCapsule(this);

        cinematicEvents = ic.readSavableArrayList("cinematicEvents", null);
        cameras = (Map<String, CameraNode>) ic.readStringSavableMap("cameras", null);
        timeLine = (TimeLine) ic.readSavable("timeLine", null);
    }

    /**
     * sets the speed of the cinematic. Note that it will set the speed of all
     * events in the cinematic. 1 is normal speed. use 0.5f to make the
     * cinematic twice slower, use 2 to make it twice faster
     *
     * @param speed the speed
     */
    @Override
    public void setSpeed(float speed) {
        super.setSpeed(speed);
        for (int i = 0; i < cinematicEvents.size(); i++) {
            CinematicEvent ce = cinematicEvents.get(i);
            ce.setSpeed(speed);
        }


    }

    /**
     * used internally
     *
     * @param stateManager the state manager
     * @param app the application
     */
    public void initialize(AppStateManager stateManager, Application app) {
        initEvent(app, this);
        for (CinematicEvent cinematicEvent : cinematicEvents) {
            cinematicEvent.initEvent(app, this);
        }

        initialized = true;
    }

    /**
     * used internally
     *
     * @return
     */
    public boolean isInitialized() {
        return initialized;
    }

    /**
     * passing true has the same effect as play() you should use play(),
     * pause(), stop() to handle the cinemaic playing state.
     *
     * @param enabled true or false
     */
    public void setEnabled(boolean enabled) {
        if (enabled) {
            play();
        }
    }

    /**
     * return true if the cinematic appstate is enabled (the cinematic is
     * playing)
     *
     * @return true if enabled
     */
    public boolean isEnabled() {
        return playState == PlayState.Playing;
    }

    /**
     * called internally
     *
     * @param stateManager the state manager
     */
    public void stateAttached(AppStateManager stateManager) {
    }

    /**
     * called internally
     *
     * @param stateManager the state manager
     */
    public void stateDetached(AppStateManager stateManager) {
        stop();
    }

    /**
     * called internally don't call it directly.
     *
     * @param tpf
     */
    public void update(float tpf) {
        if (isInitialized()) {
            internalUpdate(tpf);
        }
    }

    /**
     * used internally, don't call this directly.
     *
     * @param tpf
     */
    @Override
    public void onUpdate(float tpf) {
        int keyFrameIndex = timeLine.getKeyFrameIndexFromTime(time);

        //iterate to make sure every key frame is triggered
        for (int i = lastFetchedKeyFrame + 1; i <= keyFrameIndex; i++) {
            KeyFrame keyFrame = timeLine.get(i);
            if (keyFrame != null) {
                keyFrame.trigger();
            }
        }

        
        for (int i = 0; i < cinematicEvents.size(); i++) {
            CinematicEvent ce = cinematicEvents.get(i);
            ce.internalUpdate(tpf);
        }

        
        lastFetchedKeyFrame = keyFrameIndex;
    }

    /**
     * This is used internally but can be alled to shuffle through the
     * cinematic.
     *
     * @param time the time to shuffle to.
     */
    @Override
    public void setTime(float time) {

        //stopping all events
        onStop();
        super.setTime(time);

        int keyFrameIndex = timeLine.getKeyFrameIndexFromTime(time);
        //triggering all the event from start to "time" 
        //then computing timeOffset for each event
        for (int i = 0; i <= keyFrameIndex; i++) {
            KeyFrame keyFrame = timeLine.get(i);
            if (keyFrame != null) {
                for (CinematicEvent ce : keyFrame.getCinematicEvents()) {
                    float t = this.time - timeLine.getKeyFrameTime(keyFrame);
                    if (t >= 0 && (t <= ce.getInitialDuration() || ce.getLoopMode() != LoopMode.DontLoop)) {
                        ce.play();
                    }
                    ce.setTime(t);
                }
            }
        }
        lastFetchedKeyFrame = keyFrameIndex;
        if (playState != PlayState.Playing) {
            pause();
        }
    }

    /**
     * Adds a cinematic event to this cinematic at the given timestamp. This
     * operation returns a keyFrame
     *
     * @param timeStamp the time when the event will start after the begining of
     * the cinematic
     * @param cinematicEvent the cinematic event
     * @return the keyFrame for that event.
     */
    public KeyFrame addCinematicEvent(float timeStamp, CinematicEvent cinematicEvent) {
        KeyFrame keyFrame = timeLine.getKeyFrameAtTime(timeStamp);
        if (keyFrame == null) {
            keyFrame = new KeyFrame();
            timeLine.addKeyFrameAtTime(timeStamp, keyFrame);
        }
        keyFrame.cinematicEvents.add(cinematicEvent);
        cinematicEvents.add(cinematicEvent);
        if (isInitialized()) {
            cinematicEvent.initEvent(null, this);
        }
        return keyFrame;
    }

    /**
     * enqueue a cinematic event to a cinematic. This is a handy method when you
     * want to chain event of a given duration without knowing their initial
     * duration
     *
     * @param cinematicEvent the cinematic event to enqueue
     * @return the timestamp the evnt was scheduled.
     */
    public float enqueueCinematicEvent(CinematicEvent cinematicEvent) {
        float scheduleTime = nextEnqueue;
        addCinematicEvent(scheduleTime, cinematicEvent);
        nextEnqueue += cinematicEvent.getInitialDuration();
        return scheduleTime;
    }

    /**
     * removes the first occurrence found of the given cinematicEvent.
     *
     * @param cinematicEvent the cinematicEvent to remove
     * @return true if the element has been removed
     */
    public boolean removeCinematicEvent(CinematicEvent cinematicEvent) {
        cinematicEvent.dispose();
        cinematicEvents.remove(cinematicEvent);
        for (KeyFrame keyFrame : timeLine.values()) {
            if (keyFrame.cinematicEvents.remove(cinematicEvent)) {
                return true;
            }
        }
        return false;
    }

    /**
     * removes the first occurrence found of the given cinematicEvent for the
     * given time stamp.
     *
     * @param timeStamp the timestamp when the cinematicEvent has been added
     * @param cinematicEvent the cinematicEvent to remove
     * @return true if the element has been removed
     */
    public boolean removeCinematicEvent(float timeStamp, CinematicEvent cinematicEvent) {
        cinematicEvent.dispose();
        KeyFrame keyFrame = timeLine.getKeyFrameAtTime(timeStamp);
        return removeCinematicEvent(keyFrame, cinematicEvent);
    }

    /**
     * removes the first occurrence found of the given cinematicEvent for the
     * given keyFrame
     *
     * @param keyFrame the keyFrame returned by the addCinematicEvent method.
     * @param cinematicEvent the cinematicEvent to remove
     * @return true if the element has been removed
     */
    public boolean removeCinematicEvent(KeyFrame keyFrame, CinematicEvent cinematicEvent) {
        cinematicEvent.dispose();
        boolean ret = keyFrame.cinematicEvents.remove(cinematicEvent);
        cinematicEvents.remove(cinematicEvent);
        if (keyFrame.isEmpty()) {
            timeLine.removeKeyFrame(keyFrame.getIndex());
        }
        return ret;
    }

    /**
     * called internally
     *
     * @see AppState#render(com.jme3.renderer.RenderManager) 
     */
    public void render(RenderManager rm) {
    }

    /**
     * called internally
     *
     * @see AppState#postRender()
     */
    public void postRender() {
    }

    /**
     * called internally
     *
     * @see AppState#cleanup()
     */
    public void cleanup() {
    }

    /**
     * fits the duration of the cinamatic to the duration of all its child
     * cinematic events
     */
    public void fitDuration() {
        KeyFrame kf = timeLine.getKeyFrameAtIndex(timeLine.getLastKeyFrameIndex());
        float d = 0;
        for (int i = 0; i < kf.getCinematicEvents().size(); i++) {
            CinematicEvent ce = kf.getCinematicEvents().get(i);
            float dur = timeLine.getKeyFrameTime(kf) + ce.getDuration() * ce.getSpeed();
            if (d < dur) {
                d = dur;
            }
        }

        initialDuration = d;
    }

    /**
     * Binds a camera to this cinematic, tagged by a unique name. This methods
     * creates and returns a CameraNode for the cam and attach it to the scene.
     * The control direction is set to SpatialToCamera. This camera Node can
     * then be used in other events to handle the camera movements during the
     * playback
     *
     * @param cameraName the unique tag the camera should have
     * @param cam the scene camera.
     * @return the created CameraNode.
     */
    public CameraNode bindCamera(String cameraName, Camera cam) {
        if (cameras.containsKey(cameraName)) {
            throw new IllegalArgumentException("Camera " + cameraName + " is already binded to this cinematic");
        }
        CameraNode node = new CameraNode(cameraName, cam);
        node.setControlDir(ControlDirection.SpatialToCamera);
        node.getControl(CameraControl.class).setEnabled(false);
        cameras.put(cameraName, node);
        scene.attachChild(node);
        return node;
    }

    /**
     * returns a cameraNode given its name
     *
     * @param cameraName the camera name (as registerd in
     * Cinematic#bindCamera())
     * @return the cameraNode for this name
     */
    public CameraNode getCamera(String cameraName) {
        return cameras.get(cameraName);
    }

    /**
     * enable/disable the camera control of the cameraNode of the current cam
     *
     * @param enabled
     */
    private void setEnableCurrentCam(boolean enabled) {
        if (currentCam != null) {
            currentCam.getControl(CameraControl.class).setEnabled(enabled);
        }
    }

    /**
     * Sets the active camera instantly (use activateCamera if you want to
     * schedule that event)
     *
     * @param cameraName the camera name (as registerd in
     * Cinematic#bindCamera())
     */
    public void setActiveCamera(String cameraName) {
        setEnableCurrentCam(false);
        currentCam = cameras.get(cameraName);
        if (currentCam == null) {
            logger.log(Level.WARNING, "{0} is not a camera bond to the cinematic, cannot activate", cameraName);
        }
        setEnableCurrentCam(true);
    }

    /**
     * schedule an event that will activate the camera at the given time
     *
     * @param timeStamp the time to activate the cam
     * @param cameraName the camera name (as registerd in
     * Cinematic#bindCamera())
     */
    public void activateCamera(final float timeStamp, final String cameraName) {
        addCinematicEvent(timeStamp, new AbstractCinematicEvent() {
            @Override
            public void play() {
                super.play();
                stop();
            }

            @Override
            public void onPlay() {
                setActiveCamera(cameraName);
            }

            @Override
            public void onUpdate(float tpf) {
            }

            @Override
            public void onStop() {
            }

            @Override
            public void onPause() {
            }

            @Override
            public void forceStop() {
            }

            @Override
            public void setTime(float time) {
                play();
            }
        });
    }

    /**
     * returns the complete eventdata map
     *
     * @return the eventdata map
     */
    private Map<String, Map<Object, Object>> getEventsData() {
        if (eventsData == null) {
            eventsData = new HashMap<String, Map<Object, Object>>();
        }
        return eventsData;
    }

    /**
     * used internally put an eventdata in the cinematic
     *
     * @param type the type of data
     * @param key the key
     * @param object the data
     */
    public void putEventData(String type, Object key, Object object) {
        Map<String, Map<Object, Object>> data = getEventsData();
        Map<Object, Object> row = data.get(type);
        if (row == null) {
            row = new HashMap<Object, Object>();
        }
        row.put(key, object);
        data.put(type, row);
    }

    /**
     * used internally return and event data
     *
     * @param type the type of data
     * @param key the key
     * @return
     */
    public Object getEventData(String type, Object key) {
        if (eventsData != null) {
            Map<Object, Object> row = eventsData.get(type);
            if (row != null) {
                return row.get(key);
            }
        }
        return null;
    }

    /**
     * Used internally remove an eventData
     *
     * @param type the type of data
     * @param key the key of the data
     */
    public void removeEventData(String type, Object key) {
        if (eventsData != null) {
            Map<Object, Object> row = eventsData.get(type);
            if (row != null) {
                row.remove(key);
            }
        }
    }

    /**
     * sets the scene to use for this cinematic it is expected that the scene is
     * added before adding events to the cinematic
     *
     * @param scene the scene where the cinematic should ttake place.
     */
    public void setScene(Node scene) {
        this.scene = scene;
    }

    /**
     * return the scene where the cinematic occur
     *
     * @return the scene
     */
    public Node getScene() {
        return scene;
    }

    /**
     * clear the cinematic of its events.
     */
    public void clear() {
        dispose();
        cinematicEvents.clear();
        timeLine.clear();
        if (eventsData != null) {
            eventsData.clear();
        }
    }

    /**
     * used internally to cleanup the cinematic. Called when the clear() method
     * is called
     */
    @Override
    public void dispose() {
        for (CinematicEvent event : cinematicEvents) {
            event.dispose();
        }
    }
}
