/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * 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 android.filterpacks.videosink;

import android.filterfw.core.Filter;
import android.filterfw.core.FilterContext;
import android.filterfw.core.Frame;
import android.filterfw.core.FrameFormat;
import android.filterfw.core.GenerateFieldPort;
import android.filterfw.core.GLFrame;
import android.filterfw.core.MutableFrameFormat;
import android.filterfw.core.ShaderProgram;
import android.filterfw.format.ImageFormat;
import android.filterfw.geometry.Point;
import android.filterfw.geometry.Quad;
import android.media.MediaRecorder;
import android.media.CamcorderProfile;
import android.filterfw.core.GLEnvironment;

import java.io.IOException;
import java.io.FileDescriptor;

import android.util.Log;

/** @hide */
public class MediaEncoderFilter extends Filter {

    /** User-visible parameters */

    /** Recording state. When set to false, recording will stop, or will not
     * start if not yet running the graph. Instead, frames are simply ignored.
     * When switched back to true, recording will restart. This allows a single
     * graph to both provide preview and to record video. If this is false,
     * recording settings can be updated while the graph is running.
     */
    @GenerateFieldPort(name = "recording", hasDefault = true)
    private boolean mRecording = true;

    /** Filename to save the output. */
    @GenerateFieldPort(name = "outputFile", hasDefault = true)
    private String mOutputFile = new String("/sdcard/MediaEncoderOut.mp4");

    /** File Descriptor to save the output. */
    @GenerateFieldPort(name = "outputFileDescriptor", hasDefault = true)
    private FileDescriptor mFd = null;

    /** Input audio source. If not set, no audio will be recorded.
     * Select from the values in MediaRecorder.AudioSource
     */
    @GenerateFieldPort(name = "audioSource", hasDefault = true)
    private int mAudioSource = NO_AUDIO_SOURCE;

    /** Media recorder info listener, which needs to implement
     * MediaRecorder.OnInfoListener. Set this to receive notifications about
     * recording events.
     */
    @GenerateFieldPort(name = "infoListener", hasDefault = true)
    private MediaRecorder.OnInfoListener mInfoListener = null;

    /** Media recorder error listener, which needs to implement
     * MediaRecorder.OnErrorListener. Set this to receive notifications about
     * recording errors.
     */
    @GenerateFieldPort(name = "errorListener", hasDefault = true)
    private MediaRecorder.OnErrorListener mErrorListener = null;

    /** Media recording done callback, which needs to implement OnRecordingDoneListener.
     * Set this to finalize media upon completion of media recording.
     */
    @GenerateFieldPort(name = "recordingDoneListener", hasDefault = true)
    private OnRecordingDoneListener mRecordingDoneListener = null;

    /** Orientation hint. Used for indicating proper video playback orientation.
     * Units are in degrees of clockwise rotation, valid values are (0, 90, 180,
     * 270).
     */
    @GenerateFieldPort(name = "orientationHint", hasDefault = true)
    private int mOrientationHint = 0;

    /** Camcorder profile to use. Select from the profiles available in
     * android.media.CamcorderProfile. If this field is set, it overrides
     * settings to width, height, framerate, outputFormat, and videoEncoder.
     */
    @GenerateFieldPort(name = "recordingProfile", hasDefault = true)
    private CamcorderProfile mProfile = null;

    /** Frame width to be encoded, defaults to 320.
     * Actual received frame size has to match this */
    @GenerateFieldPort(name = "width", hasDefault = true)
    private int mWidth = 0;

    /** Frame height to to be encoded, defaults to 240.
     * Actual received frame size has to match */
    @GenerateFieldPort(name = "height", hasDefault = true)
    private int mHeight = 0;

    /** Stream framerate to encode the frames at.
     * By default, frames are encoded at 30 FPS*/
    @GenerateFieldPort(name = "framerate", hasDefault = true)
    private int mFps = 30;

    /** The output format to encode the frames in.
     * Choose an output format from the options in
     * android.media.MediaRecorder.OutputFormat */
    @GenerateFieldPort(name = "outputFormat", hasDefault = true)
    private int mOutputFormat = MediaRecorder.OutputFormat.MPEG_4;

    /** The videoencoder to encode the frames with.
     * Choose a videoencoder from the options in
     * android.media.MediaRecorder.VideoEncoder */
    @GenerateFieldPort(name = "videoEncoder", hasDefault = true)
    private int mVideoEncoder = MediaRecorder.VideoEncoder.H264;

    /** The input region to read from the frame. The corners of this quad are
     * mapped to the output rectangle. The input frame ranges from (0,0)-(1,1),
     * top-left to bottom-right. The corners of the quad are specified in the
     * order bottom-left, bottom-right, top-left, top-right.
     */
    @GenerateFieldPort(name = "inputRegion", hasDefault = true)
    private Quad mSourceRegion;

    /** The maximum filesize (in bytes) of the recording session.
     * By default, it will be 0 and will be passed on to the MediaRecorder.
     * If the limit is zero or negative, MediaRecorder will disable the limit*/
    @GenerateFieldPort(name = "maxFileSize", hasDefault = true)
    private long mMaxFileSize = 0;

    /** The maximum duration (in milliseconds) of the recording session.
     * By default, it will be 0 and will be passed on to the MediaRecorder.
     * If the limit is zero or negative, MediaRecorder will record indefinitely*/
    @GenerateFieldPort(name = "maxDurationMs", hasDefault = true)
    private int mMaxDurationMs = 0;

    /** TimeLapse Interval between frames.
     * By default, it will be 0. Whether the recording is timelapsed
     * is inferred based on its value being greater than 0 */
    @GenerateFieldPort(name = "timelapseRecordingIntervalUs", hasDefault = true)
    private long mTimeBetweenTimeLapseFrameCaptureUs = 0;

    // End of user visible parameters

    private static final int NO_AUDIO_SOURCE = -1;

    private int mSurfaceId;
    private ShaderProgram mProgram;
    private GLFrame mScreen;

    private boolean mRecordingActive = false;
    private long mTimestampNs = 0;
    private long mLastTimeLapseFrameRealTimestampNs = 0;
    private int mNumFramesEncoded = 0;
    // Used to indicate whether recording is timelapsed.
    // Inferred based on (mTimeBetweenTimeLapseFrameCaptureUs > 0)
    private boolean mCaptureTimeLapse = false;

    private boolean mLogVerbose;
    private static final String TAG = "MediaEncoderFilter";

    // Our hook to the encoder
    private MediaRecorder mMediaRecorder;

    /** Callback to be called when media recording completes. */

    public interface OnRecordingDoneListener {
        public void onRecordingDone();
    }

    public MediaEncoderFilter(String name) {
        super(name);
        Point bl = new Point(0, 0);
        Point br = new Point(1, 0);
        Point tl = new Point(0, 1);
        Point tr = new Point(1, 1);
        mSourceRegion = new Quad(bl, br, tl, tr);
        mLogVerbose = Log.isLoggable(TAG, Log.VERBOSE);
    }

    @Override
    public void setupPorts() {
        // Add input port- will accept RGBA GLFrames
        addMaskedInputPort("videoframe", ImageFormat.create(ImageFormat.COLORSPACE_RGBA,
                                                      FrameFormat.TARGET_GPU));
    }

    @Override
    public void fieldPortValueUpdated(String name, FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Port " + name + " has been updated");
        if (name.equals("recording")) return;
        if (name.equals("inputRegion")) {
            if (isOpen()) updateSourceRegion();
            return;
        }
        // TODO: Not sure if it is possible to update the maxFileSize
        // when the recording is going on. For now, not doing that.
        if (isOpen() && mRecordingActive) {
            throw new RuntimeException("Cannot change recording parameters"
                                       + " when the filter is recording!");
        }
    }

    private void updateSourceRegion() {
        // Flip source quad to map to OpenGL origin
        Quad flippedRegion = new Quad();
        flippedRegion.p0 = mSourceRegion.p2;
        flippedRegion.p1 = mSourceRegion.p3;
        flippedRegion.p2 = mSourceRegion.p0;
        flippedRegion.p3 = mSourceRegion.p1;
        mProgram.setSourceRegion(flippedRegion);
    }

    // update the MediaRecorderParams based on the variables.
    // These have to be in certain order as per the MediaRecorder
    // documentation
    private void updateMediaRecorderParams() {
        mCaptureTimeLapse = mTimeBetweenTimeLapseFrameCaptureUs > 0;
        final int GRALLOC_BUFFER = 2;
        mMediaRecorder.setVideoSource(GRALLOC_BUFFER);
        if (!mCaptureTimeLapse && (mAudioSource != NO_AUDIO_SOURCE)) {
            mMediaRecorder.setAudioSource(mAudioSource);
        }
        if (mProfile != null) {
            mMediaRecorder.setProfile(mProfile);
            mFps = mProfile.videoFrameRate;
            // If width and height are set larger than 0, then those
            // overwrite the ones in the profile.
            if (mWidth > 0 && mHeight > 0) {
                mMediaRecorder.setVideoSize(mWidth, mHeight);
            }
        } else {
            mMediaRecorder.setOutputFormat(mOutputFormat);
            mMediaRecorder.setVideoEncoder(mVideoEncoder);
            mMediaRecorder.setVideoSize(mWidth, mHeight);
            mMediaRecorder.setVideoFrameRate(mFps);
        }
        mMediaRecorder.setOrientationHint(mOrientationHint);
        mMediaRecorder.setOnInfoListener(mInfoListener);
        mMediaRecorder.setOnErrorListener(mErrorListener);
        if (mFd != null) {
            mMediaRecorder.setOutputFile(mFd);
        } else {
            mMediaRecorder.setOutputFile(mOutputFile);
        }
        try {
            mMediaRecorder.setMaxFileSize(mMaxFileSize);
        } catch (Exception e) {
            // Following the logic in  VideoCamera.java (in Camera app)
            // We are going to ignore failure of setMaxFileSize here, as
            // a) The composer selected may simply not support it, or
            // b) The underlying media framework may not handle 64-bit range
            // on the size restriction.
            Log.w(TAG, "Setting maxFileSize on MediaRecorder unsuccessful! "
                    + e.getMessage());
        }
        mMediaRecorder.setMaxDuration(mMaxDurationMs);
    }

    @Override
    public void prepare(FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Preparing");

        mProgram = ShaderProgram.createIdentity(context);

        mRecordingActive = false;
    }

    @Override
    public void open(FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Opening");
        updateSourceRegion();
        if (mRecording) startRecording(context);
    }

    private void startRecording(FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Starting recording");

        // Create a frame representing the screen
        MutableFrameFormat screenFormat = new MutableFrameFormat(
                              FrameFormat.TYPE_BYTE, FrameFormat.TARGET_GPU);
        screenFormat.setBytesPerSample(4);

        int width, height;
        boolean widthHeightSpecified = mWidth > 0 && mHeight > 0;
        // If width and height are specified, then use those instead
        // of that in the profile.
        if (mProfile != null && !widthHeightSpecified) {
            width = mProfile.videoFrameWidth;
            height = mProfile.videoFrameHeight;
        } else {
            width = mWidth;
            height = mHeight;
        }
        screenFormat.setDimensions(width, height);
        mScreen = (GLFrame)context.getFrameManager().newBoundFrame(
                           screenFormat, GLFrame.EXISTING_FBO_BINDING, 0);

        // Initialize the media recorder

        mMediaRecorder = new MediaRecorder();
        updateMediaRecorderParams();

        try {
            mMediaRecorder.prepare();
        } catch (IllegalStateException e) {
            throw e;
        } catch (IOException e) {
            throw new RuntimeException("IOException in"
                    + "MediaRecorder.prepare()!", e);
        } catch (Exception e) {
            throw new RuntimeException("Unknown Exception in"
                    + "MediaRecorder.prepare()!", e);
        }
        // Make sure start() is called before trying to
        // register the surface. The native window handle needed to create
        // the surface is initiated in start()
        mMediaRecorder.start();
        if (mLogVerbose) Log.v(TAG, "Open: registering surface from Mediarecorder");
        mSurfaceId = context.getGLEnvironment().
                registerSurfaceFromMediaRecorder(mMediaRecorder);
        mNumFramesEncoded = 0;
        mRecordingActive = true;
    }

    public boolean skipFrameAndModifyTimestamp(long timestampNs) {
        // first frame- encode. Don't skip
        if (mNumFramesEncoded == 0) {
            mLastTimeLapseFrameRealTimestampNs = timestampNs;
            mTimestampNs = timestampNs;
            if (mLogVerbose) Log.v(TAG, "timelapse: FIRST frame, last real t= "
                    + mLastTimeLapseFrameRealTimestampNs +
                    ", setting t = " + mTimestampNs );
            return false;
        }

        // Workaround to bypass the first 2 input frames for skipping.
        // The first 2 output frames from the encoder are: decoder specific info and
        // the compressed video frame data for the first input video frame.
        if (mNumFramesEncoded >= 2 && timestampNs <
            (mLastTimeLapseFrameRealTimestampNs +  1000L * mTimeBetweenTimeLapseFrameCaptureUs)) {
            // If 2 frames have been already encoded,
            // Skip all frames from last encoded frame until
            // sufficient time (mTimeBetweenTimeLapseFrameCaptureUs) has passed.
            if (mLogVerbose) Log.v(TAG, "timelapse: skipping intermediate frame");
            return true;
        } else {
            // Desired frame has arrived after mTimeBetweenTimeLapseFrameCaptureUs time:
            // - Reset mLastTimeLapseFrameRealTimestampNs to current time.
            // - Artificially modify timestampNs to be one frame time (1/framerate) ahead
            // of the last encoded frame's time stamp.
            if (mLogVerbose) Log.v(TAG, "timelapse: encoding frame, Timestamp t = " + timestampNs +
                    ", last real t= " + mLastTimeLapseFrameRealTimestampNs +
                    ", interval = " + mTimeBetweenTimeLapseFrameCaptureUs);
            mLastTimeLapseFrameRealTimestampNs = timestampNs;
            mTimestampNs = mTimestampNs + (1000000000L / (long)mFps);
            if (mLogVerbose) Log.v(TAG, "timelapse: encoding frame, setting t = "
                    + mTimestampNs + ", delta t = " + (1000000000L / (long)mFps) +
                    ", fps = " + mFps );
            return false;
        }
    }

    @Override
    public void process(FilterContext context) {
        GLEnvironment glEnv = context.getGLEnvironment();
        // Get input frame
        Frame input = pullInput("videoframe");

        // Check if recording needs to start
        if (!mRecordingActive && mRecording) {
            startRecording(context);
        }
        // Check if recording needs to stop
        if (mRecordingActive && !mRecording) {
            stopRecording(context);
        }

        if (!mRecordingActive) return;

        if (mCaptureTimeLapse) {
            if (skipFrameAndModifyTimestamp(input.getTimestamp())) {
                return;
            }
        } else {
            mTimestampNs = input.getTimestamp();
        }

        // Activate our surface
        glEnv.activateSurfaceWithId(mSurfaceId);

        // Process
        mProgram.process(input, mScreen);

        // Set timestamp from input
        glEnv.setSurfaceTimestamp(mTimestampNs);
        // And swap buffers
        glEnv.swapBuffers();
        mNumFramesEncoded++;
    }

    private void stopRecording(FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Stopping recording");

        mRecordingActive = false;
        mNumFramesEncoded = 0;
        GLEnvironment glEnv = context.getGLEnvironment();
        // The following call will switch the surface_id to 0
        // (thus, calling eglMakeCurrent on surface with id 0) and
        // then call eglDestroy on the surface. Hence, this will
        // call disconnect the SurfaceMediaSource, which is needed to
        // be called before calling Stop on the mediarecorder
        if (mLogVerbose) Log.v(TAG, String.format("Unregistering surface %d", mSurfaceId));
        glEnv.unregisterSurfaceId(mSurfaceId);
        try {
            mMediaRecorder.stop();
        } catch (RuntimeException e) {
            throw new MediaRecorderStopException("MediaRecorder.stop() failed!", e);
        }
        mMediaRecorder.release();
        mMediaRecorder = null;

        mScreen.release();
        mScreen = null;

        // Use an EffectsRecorder callback to forward a media finalization
        // call so that it creates the video thumbnail, and whatever else needs
        // to be done to finalize media.
        if (mRecordingDoneListener != null) {
            mRecordingDoneListener.onRecordingDone();
        }
    }

    @Override
    public void close(FilterContext context) {
        if (mLogVerbose) Log.v(TAG, "Closing");
        if (mRecordingActive) stopRecording(context);
    }

    @Override
    public void tearDown(FilterContext context) {
        // Release all the resources associated with the MediaRecorder
        // and GLFrame members
        if (mMediaRecorder != null) {
            mMediaRecorder.release();
        }
        if (mScreen != null) {
            mScreen.release();
        }

    }

}
