/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko;

import java.io.IOException;

import org.mozilla.gecko.util.EventCallback;
import org.json.JSONObject;
import org.json.JSONException;

import com.google.android.gms.cast.Cast.MessageReceivedCallback;
import com.google.android.gms.cast.ApplicationMetadata;
import com.google.android.gms.cast.Cast;
import com.google.android.gms.cast.Cast.ApplicationConnectionResult;
import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.RemoteMediaPlayer;
import com.google.android.gms.cast.RemoteMediaPlayer.MediaChannelResult;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.common.GooglePlayServicesUtil;

import android.content.Context;
import android.os.Bundle;
import android.support.v7.media.MediaRouter.RouteInfo;
import android.util.Log;

/* Implementation of GeckoMediaPlayer for talking to ChromeCast devices */
class ChromeCast implements GeckoMediaPlayer {
    private static final boolean SHOW_DEBUG = false;

    static final String MIRROR_RECEIVER_APP_ID = "08FF1091";

    private final Context context;
    private final RouteInfo route;
    private GoogleApiClient apiClient;
    private RemoteMediaPlayer remoteMediaPlayer;
    private final boolean canMirror;
    private String mSessionId;
    private MirrorChannel mMirrorChannel;
    private boolean mApplicationStarted = false;

    // EventCallback which is actually a GeckoEventCallback is sometimes being invoked more
    // than once. That causes the IllegalStateException to be thrown. To prevent a crash,
    // catch the exception and report it as an error to the log.
    private static void sendSuccess(final EventCallback callback, final String msg) {
        try {
            callback.sendSuccess(msg);
        } catch (final IllegalStateException e) {
            Log.e(LOGTAG, "Attempting to invoke callback.sendSuccess more than once.", e);
        }
    }

    private static void sendError(final EventCallback callback, final String msg) {
        try {
            callback.sendError(msg);
        } catch (final IllegalStateException e) {
            Log.e(LOGTAG, "Attempting to invoke callback.sendError more than once.", e);
        }
    }

    // Callback to start playback of a url on a remote device
    private class VideoPlayCallback implements ResultCallback<ApplicationConnectionResult>,
                                               RemoteMediaPlayer.OnStatusUpdatedListener,
                                               RemoteMediaPlayer.OnMetadataUpdatedListener {
        private final String url;
        private final String type;
        private final String title;
        private final EventCallback callback;

        public VideoPlayCallback(String url, String type, String title, EventCallback callback) {
            this.url = url;
            this.type = type;
            this.title = title;
            this.callback = callback;
        }

        @Override
        public void onStatusUpdated() {
            MediaStatus mediaStatus = remoteMediaPlayer.getMediaStatus();

            switch (mediaStatus.getPlayerState()) {
            case MediaStatus.PLAYER_STATE_PLAYING:
                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("MediaPlayer:Playing", null));
                break;
            case MediaStatus.PLAYER_STATE_PAUSED:
                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("MediaPlayer:Paused", null));
                break;
            case MediaStatus.PLAYER_STATE_IDLE:
                // TODO: Do we want to shutdown when there are errors?
                if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_FINISHED) {
                    GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Casting:Stop", null));
                }
                break;
            default:
                // TODO: Do we need to handle other status such as buffering / unknown?
                break;
            }
        }

        @Override
        public void onMetadataUpdated() { }

        @Override
        public void onResult(ApplicationConnectionResult result) {
            Status status = result.getStatus();
            debug("ApplicationConnectionResultCallback.onResult: statusCode" + status.getStatusCode());
            if (status.isSuccess()) {
                remoteMediaPlayer = new RemoteMediaPlayer();
                remoteMediaPlayer.setOnStatusUpdatedListener(this);
                remoteMediaPlayer.setOnMetadataUpdatedListener(this);
                mSessionId = result.getSessionId();
                if (!verifySession(callback)) {
                    return;
                }

                try {
                    Cast.CastApi.setMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace(), remoteMediaPlayer);
                } catch (IOException e) {
                    debug("Exception while creating media channel", e);
                }

                startPlayback();
            } else {
                sendError(callback, status.toString());
            }
        }

        private void startPlayback() {
            MediaMetadata mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
            mediaMetadata.putString(MediaMetadata.KEY_TITLE, title);
            MediaInfo mediaInfo = new MediaInfo.Builder(url)
                                               .setContentType(type)
                                               .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
                                               .setMetadata(mediaMetadata)
                                               .build();
            try {
                remoteMediaPlayer.load(apiClient, mediaInfo, true).setResultCallback(new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() {
                    @Override
                    public void onResult(MediaChannelResult result) {
                        if (result.getStatus().isSuccess()) {
                            sendSuccess(callback, null);
                            debug("Media loaded successfully");
                            return;
                        }

                        debug("Media load failed " + result.getStatus());
                        sendError(callback, result.getStatus().toString());
                    }
                });

                return;
            } catch (IllegalStateException e) {
                debug("Problem occurred with media during loading", e);
            } catch (Exception e) {
                debug("Problem opening media during loading", e);
            }

            sendError(callback, "");
        }
    }

    public ChromeCast(Context context, RouteInfo route) {
        int status =  GooglePlayServicesUtil.isGooglePlayServicesAvailable(context);
        if (status != ConnectionResult.SUCCESS) {
            throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")");
        }

        this.context = context;
        this.route = route;
        this.canMirror = route.supportsControlCategory(CastMediaControlIntent.categoryForCast(MIRROR_RECEIVER_APP_ID));
    }

    /**
     *  This dumps everything we can find about the device into JSON. This will hopefully make it
     *  easier to filter out duplicate devices from different sources in JS.
     *  Returns null if the device can't be found.
     */
    @Override
    public JSONObject toJSON() {
        final JSONObject obj = new JSONObject();
        try {
            final CastDevice device = CastDevice.getFromBundle(route.getExtras());
            if (device == null) {
                return null;
            }

            obj.put("uuid", route.getId());
            obj.put("version", device.getDeviceVersion());
            obj.put("friendlyName", device.getFriendlyName());
            obj.put("location", device.getIpAddress().toString());
            obj.put("modelName", device.getModelName());
            obj.put("mirror", canMirror);
            // For now we just assume all of these are Google devices
            obj.put("manufacturer", "Google Inc.");
        } catch (JSONException ex) {
            debug("Error building route", ex);
        }

        return obj;
    }

    @Override
    public void load(final String title, final String url, final String type, final EventCallback callback) {
        final CastDevice device = CastDevice.getFromBundle(route.getExtras());
        Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
            @Override
            public void onApplicationStatusChanged() { }

            @Override
            public void onVolumeChanged() { }

            @Override
            public void onApplicationDisconnected(int errorCode) { }
        });

        apiClient = new GoogleApiClient.Builder(context)
            .addApi(Cast.API, apiOptionsBuilder.build())
            .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
                @Override
                public void onConnected(Bundle connectionHint) {
                    // Sometimes apiClient is null here. See bug 1061032
                    if (apiClient != null && !apiClient.isConnected()) {
                        debug("Connection failed");
                        sendError(callback, "Not connected");
                        return;
                    }

                    // Launch the media player app and launch this url once its loaded
                    try {
                        Cast.CastApi.launchApplication(apiClient, CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID, true)
                                    .setResultCallback(new VideoPlayCallback(url, type, title, callback));
                    } catch (Exception e) {
                        debug("Failed to launch application", e);
                    }
                }

                @Override
                public void onConnectionSuspended(int cause) {
                    debug("suspended");
                }
        }).build();

        apiClient.connect();
    }

    @Override
    public void start(final EventCallback callback) {
        // Nothing to be done here
        sendSuccess(callback, null);
    }

    @Override
    public void stop(final EventCallback callback) {
        // Nothing to be done here
        sendSuccess(callback, null);
    }

    public boolean verifySession(final EventCallback callback) {
        String msg = null;
        if (apiClient == null || !apiClient.isConnected()) {
            msg = "Not connected";
        }

        if (mSessionId == null) {
            msg = "No session";
        }

        if (msg != null) {
            debug(msg);
            if (callback != null) {
                sendError(callback, msg);
            }
            return false;
        }

        return true;
    }

    @Override
    public void play(final EventCallback callback) {
        if (!verifySession(callback)) {
            return;
        }

        try {
            remoteMediaPlayer.play(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() {
                @Override
                public void onResult(MediaChannelResult result) {
                    Status status = result.getStatus();
                    if (!status.isSuccess()) {
                        debug("Unable to play: " + status.getStatusCode());
                        sendError(callback, status.toString());
                    } else {
                        sendSuccess(callback, null);
                    }
                }
            });
        } catch(IllegalStateException ex) {
            // The media player may throw if the session has been killed. For now, we're just catching this here.
            sendError(callback, "Error playing");
        }
    }

    @Override
    public void pause(final EventCallback callback) {
        if (!verifySession(callback)) {
            return;
        }

        try {
            remoteMediaPlayer.pause(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() {
                @Override
                public void onResult(MediaChannelResult result) {
                    Status status = result.getStatus();
                    if (!status.isSuccess()) {
                        debug("Unable to pause: " + status.getStatusCode());
                        sendError(callback, status.toString());
                    } else {
                        sendSuccess(callback, null);
                    }
                }
            });
        } catch(IllegalStateException ex) {
            // The media player may throw if the session has been killed. For now, we're just catching this here.
            sendError(callback, "Error pausing");
        }
    }

    @Override
    public void end(final EventCallback callback) {
        if (!verifySession(callback)) {
            return;
        }

        try {
            Cast.CastApi.stopApplication(apiClient).setResultCallback(new ResultCallback<Status>() {
                @Override
                public void onResult(Status result) {
                    if (result.isSuccess()) {
                        try {
                            Cast.CastApi.removeMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace());
                            remoteMediaPlayer = null;
                            mSessionId = null;
                            apiClient.disconnect();
                            apiClient = null;

                            if (callback != null) {
                                sendSuccess(callback, null);
                            }

                            return;
                        } catch(Exception ex) {
                            debug("Error ending", ex);
                        }
                    }

                    if (callback != null) {
                        sendError(callback, result.getStatus().toString());
                    }
                }
            });
        } catch(IllegalStateException ex) {
            // The media player may throw if the session has been killed. For now, we're just catching this here.
            sendError(callback, "Error stopping");
        }
    }

    class MirrorChannel implements MessageReceivedCallback {
        /**
         * @return custom namespace
         */
        public String getNamespace() {
            return "urn:x-cast:org.mozilla.mirror";
        }

        /*
         * Receive message from the receiver app
         */
        @Override
        public void onMessageReceived(CastDevice castDevice, String namespace,
                                      String message) {
            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("MediaPlayer:Response", message));
        }

        public void sendMessage(String message) {
            if (apiClient != null && mMirrorChannel != null) {
                try {
                    Cast.CastApi.sendMessage(apiClient, mMirrorChannel.getNamespace(), message)
                        .setResultCallback(
                                           new ResultCallback<Status>() {
                                               @Override
                                                   public void onResult(Status result) {
                                               }
                                           });
                } catch (Exception e) {
                    Log.e(LOGTAG, "Exception while sending message", e);
                }
            }
        }
    }
    private class MirrorCallback implements ResultCallback<ApplicationConnectionResult> {
        final EventCallback callback;
        MirrorCallback(final EventCallback callback) {
            this.callback = callback;
        }


        @Override
        public void onResult(ApplicationConnectionResult result) {
            Status status = result.getStatus();
            if (status.isSuccess()) {
                ApplicationMetadata applicationMetadata = result.getApplicationMetadata();
                mSessionId = result.getSessionId();
                String applicationStatus = result.getApplicationStatus();
                boolean wasLaunched = result.getWasLaunched();
                mApplicationStarted = true;

                // Create the custom message
                // channel
                mMirrorChannel = new MirrorChannel();
                try {
                    Cast.CastApi.setMessageReceivedCallbacks(apiClient,
                                                             mMirrorChannel
                                                             .getNamespace(),
                                                             mMirrorChannel);
                    sendSuccess(callback, null);
                } catch (IOException e) {
                    Log.e(LOGTAG, "Exception while creating channel", e);
                }

                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Casting:Mirror", route.getId()));
            } else {
                sendError(callback, status.toString());
            }
        }
    }

    @Override
    public void message(String msg, final EventCallback callback) {
        if (mMirrorChannel != null) {
            mMirrorChannel.sendMessage(msg);
        }
    }

    @Override
    public void mirror(final EventCallback callback) {
        final CastDevice device = CastDevice.getFromBundle(route.getExtras());
        Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
                @Override
                public void onApplicationStatusChanged() { }

                @Override
                public void onVolumeChanged() { }

                @Override
                public void onApplicationDisconnected(int errorCode) { }
            });

        apiClient = new GoogleApiClient.Builder(context)
            .addApi(Cast.API, apiOptionsBuilder.build())
            .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
                    @Override
                    public void onConnected(Bundle connectionHint) {
                        // Sometimes apiClient is null here. See bug 1061032
                        if (apiClient == null || !apiClient.isConnected()) {
                            return;
                        }

                        // Launch the media player app and launch this url once its loaded
                        try {
                            Cast.CastApi.launchApplication(apiClient, MIRROR_RECEIVER_APP_ID, true)
                                .setResultCallback(new MirrorCallback(callback));
                        } catch (Exception e) {
                            debug("Failed to launch application", e);
                        }
                    }

                    @Override
                    public void onConnectionSuspended(int cause) {
                        debug("suspended");
                    }
                }).build();

        apiClient.connect();
    }

    private static final String LOGTAG = "GeckoChromeCast";
    private void debug(String msg, Exception e) {
        if (SHOW_DEBUG) {
            Log.e(LOGTAG, msg, e);
        }
    }

    private void debug(String msg) {
        if (SHOW_DEBUG) {
            Log.d(LOGTAG, msg);
        }
    }

}
