/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* 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/. */
/* jshint esnext:true, globalstrict:true, moz:true, undef:true, unused:true */
/* globals Components, dump */
"use strict";

const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;

// globals XPCOMUtils
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
// globals Services
Cu.import("resource://gre/modules/Services.jsm");
// globals Messaging
Cu.import("resource://gre/modules/Messaging.jsm");

function log(str) {
  // dump("-*- AndroidCastDeviceProvider -*-: " + str + "\n");
}

// Helper function: transfer nsIPresentationChannelDescription to json
function descriptionToString(aDescription) {
  let json = {};
  json.type = aDescription.type;
  switch(aDescription.type) {
    case Ci.nsIPresentationChannelDescription.TYPE_TCP:
      let addresses = aDescription.tcpAddress.QueryInterface(Ci.nsIArray);
      json.tcpAddress = [];
      for (let idx = 0; idx < addresses.length; idx++) {
        let address = addresses.queryElementAt(idx, Ci.nsISupportsCString);
        json.tcpAddress.push(address.data);
      }
      json.tcpPort = aDescription.tcpPort;
      break;
    case Ci.nsIPresentationChannelDescription.TYPE_DATACHANNEL:
      json.dataChannelSDP = aDescription.dataChannelSDP;
      break;
  }
  return JSON.stringify(json);
}

const TOPIC_ANDROID_CAST_DEVICE_SYNCDEVICE = "AndroidCastDevice:SyncDevice";
const TOPIC_ANDROID_CAST_DEVICE_ADDED      = "AndroidCastDevice:Added";
const TOPIC_ANDROID_CAST_DEVICE_REMOVED    = "AndroidCastDevice:Removed";
const TOPIC_ANDROID_CAST_DEVICE_START      = "AndroidCastDevice:Start";
const TOPIC_ANDROID_CAST_DEVICE_STOP       = "AndroidCastDevice:Stop";
const TOPIC_PRESENTATION_VIEW_READY        = "presentation-view-ready";

function LocalControlChannel(aProvider, aDeviceId, aRole) {
  log("LocalControlChannel - create new LocalControlChannel for : "
      + aRole);
  this._provider = aProvider;
  this._deviceId = aDeviceId;
  this._role     = aRole;
}

LocalControlChannel.prototype = {
  _listener: null,
  _provider: null,
  _deviceId: null,
  _role: null,
  _isOnTerminating: false,
  _isOnDisconnecting: false,
  _pendingConnected: false,
  _pendingDisconnect: null,
  _pendingOffer: null,
  _pendingCandidate: null,
  /* For the controller, it would be the control channel of the receiver.
   * For the receiver, it would be the control channel of the controller. */
  _correspondingControlChannel: null,

  set correspondingControlChannel(aCorrespondingControlChannel) {
    this._correspondingControlChannel = aCorrespondingControlChannel;
  },

  get correspondingControlChannel() {
    return this._correspondingControlChannel;
  },

  notifyConnected: function LCC_notifyConnected() {
    this._pendingDisconnect = null;

    if (!this._listener) {
      this._pendingConnected = true;
    } else {
      this._listener.notifyConnected();
    }
  },

  onOffer: function LCC_onOffer(aOffer) {
    if (this._role == Ci.nsIPresentationService.ROLE_CONTROLLER) {
      log("LocalControlChannel - onOffer of controller should not be called.");
      return;
    }
    if (!this._listener) {
      this._pendingOffer = aOffer;
    } else {
      this._listener.onOffer(aOffer);
    }
  },

  onAnswer: function LCC_onAnswer(aAnswer) {
    if (this._role == Ci.nsIPresentationService.ROLE_RECEIVER) {
      log("LocalControlChannel - onAnswer of receiver should not be called.");
      return;
    }
    this._listener.onAnswer(aAnswer);
  },

  notifyIceCandidate: function LCC_notifyIceCandidate(aCandidate) {
    if (!this._listener) {
      this._pendingCandidate = aCandidate;
    } else {
      this._listener.onIceCandidate(aCandidate);
    }
  },

  // nsIPresentationControlChannel
  get listener() {
    return this._listener;
  },

  set listener(aListener) {
    this._listener = aListener;

    if (!this._listener) {
      return;
    }

    if (this._pendingConnected) {
      this.notifyConnected();
      this._pendingConnected = false;
    }

    if (this._pendingOffer) {
      this.onOffer(this._pendingOffer);
      this._pendingOffer = null;
    }

    if (this._pendingCandidate) {
      this.notifyIceCandidate(this._pendingCandidate);
      this._pendingCandidate = null;
    }

    if (this._pendingDisconnect != null) {
      this.disconnect(this._pendingDisconnect);
      this._pendingDisconnect = null;
    }
  },

  sendOffer: function LCC_sendOffer(aOffer) {
    if (this._role == Ci.nsIPresentationService.ROLE_RECEIVER) {
      log("LocalControlChannel - sendOffer of receiver should not be called.");
      return;
    }
    log("LocalControlChannel - sendOffer aOffer=" + descriptionToString(aOffer));
    this._correspondingControlChannel.onOffer(aOffer);
  },

  sendAnswer: function LCC_sendAnswer(aAnswer) {
    if (this._role == Ci.nsIPresentationService.ROLE_CONTROLLER) {
      log("LocalControlChannel - sendAnswer of controller should not be called.");
      return;
    }
    log("LocalControlChannel - sendAnswer aAnswer=" + descriptionToString(aAnswer));
    this._correspondingControlChannel.onAnswer(aAnswer);
  },

  sendIceCandidate: function LCC_sendIceCandidate(aCandidate) {
    log("LocalControlChannel - sendAnswer aCandidate=" + aCandidate);
    this._correspondingControlChannel.notifyIceCandidate(aCandidate);
  },

  launch: function LCC_launch(aPresentationId, aUrl) {
    log("LocalControlChannel - launch aPresentationId="
        + aPresentationId + " aUrl=" + aUrl);
    // Create control channel for receiver directly.
    let controlChannel = new LocalControlChannel(this._provider,
                                                 this._deviceId,
                                                 Ci.nsIPresentationService.ROLE_RECEIVER);

    // Set up the corresponding control channels for both controller and receiver.
    this._correspondingControlChannel = controlChannel;
    controlChannel._correspondingControlChannel = this;

    this._provider.onSessionRequest(this._deviceId,
                                    aUrl,
                                    aPresentationId,
                                    controlChannel);
    controlChannel.notifyConnected();
  },

  terminate: function LCC_terminate(aPresentationId) {
    log("LocalControlChannel - terminate aPresentationId="
        + aPresentationId);

    if (this._isOnTerminating) {
      return;
    }

    // Create control channel for corresponding role directly.
    let correspondingRole = this._role == Ci.nsIPresentationService.ROLE_CONTROLLER
                          ? Ci.nsIPresentationService.ROLE_RECEIVER
                          : Ci.nsIPresentationService.ROLE_CONTROLLER;
    let controlChannel = new LocalControlChannel(this._provider,
                                                 this._deviceId,
                                                 correspondingRole);
    // Prevent the termination recursion.
    controlChannel._isOnTerminating = true;

    // Set up the corresponding control channels for both controller and receiver.
    this._correspondingControlChannel = controlChannel;
    controlChannel._correspondingControlChannel = this;

    this._provider.onTerminateRequest(this._deviceId,
                                      aPresentationId,
                                      controlChannel,
                                      this._role == Ci.nsIPresentationService.ROLE_RECEIVER);
    controlChannel.notifyConnected();
  },

  disconnect: function LCC_disconnect(aReason) {
    log("LocalControlChannel - disconnect aReason=" + aReason);

    if (this._isOnDisconnecting) {
      return;
    }

    this._pendingOffer = null;
    this._pendingCandidate = null;
    this._pendingConnected = false;

    // this._pendingDisconnect is a nsresult.
    // If it is null, it means no pending disconnect.
    // If it is NS_OK, it means this control channel is disconnected normally.
    // If it is other nsresult value, it means this control channel is
    // disconnected abnormally.

    // Remote endpoint closes the control channel with abnormal reason.
    if (aReason == Cr.NS_OK &&
        this._pendingDisconnect != null &&
        this._pendingDisconnect != Cr.NS_OK) {
      aReason = this._pendingDisconnect;
    }

    if (!this._listener) {
      this._pendingDisconnect = aReason;
      return;
    }

    this._isOnDisconnecting = true;
    this._correspondingControlChannel.disconnect(aReason);
    this._listener.notifyDisconnected(aReason);
  },

  reconnect: function LCC_reconnect(aPresentationId, aUrl) {
    log("1-UA on Android doesn't support reconnect.");
    throw Cr.NS_ERROR_FAILURE;
  },

  classID: Components.ID("{c9be9450-e5c7-4294-a287-376971b017fd}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIPresentationControlChannel]),
};

function ChromecastRemoteDisplayDevice(aProvider, aId, aName, aRole) {
  this._provider = aProvider;
  this._id       = aId;
  this._name     = aName;
  this._role     = aRole;
}

ChromecastRemoteDisplayDevice.prototype = {
  _id:          null,
  _name:        null,
  _role:        null,
  _provider:    null,
  _ctrlChannel: null,

  update: function CRDD_update(aName) {
    this._name = aName || this._name;
  },

  // nsIPresentationDevice
  get id() { return this._id; },

  get name() { return this._name; },

  get type() { return "chromecast"; },

  establishControlChannel: function CRDD_establishControlChannel() {
    this._ctrlChannel = new LocalControlChannel(this._provider,
                                                this._id,
                                                this._role);

    if (this._role == Ci.nsIPresentationService.ROLE_CONTROLLER) {
      // Only connect to Chromecast for controller.
      // Monitor the receiver being ready.
      Services.obs.addObserver(this, TOPIC_PRESENTATION_VIEW_READY, true);

      // Launch Chromecast service in Android.
      Messaging.sendRequestForResult({
        type: TOPIC_ANDROID_CAST_DEVICE_START,
        id:   this.id
      }).then(result => {
        log("Chromecast is connected.");
      }).catch(error => {
        log("Can not connect to Chromecast.");
        // If Chromecast can not be launched, remove the observer.
        Services.obs.removeObserver(this, TOPIC_PRESENTATION_VIEW_READY);
        this._ctrlChannel.disconnect(Cr.NS_ERROR_FAILURE);
      });
    } else {
      // If establishControlChannel called from the receiver, we don't need to
      // wait the 'presentation-view-ready' event.
      this._ctrlChannel.notifyConnected();
    }

    return this._ctrlChannel;
  },

  disconnect: function CRDD_disconnect() {
    // Disconnect from Chromecast.
    Messaging.sendRequestForResult({
      type: TOPIC_ANDROID_CAST_DEVICE_STOP,
      id:   this.id
    });
  },

  isRequestedUrlSupported: function CRDD_isRequestedUrlSupported(aUrl) {
    let url = Cc["@mozilla.org/network/io-service;1"]
                .getService(Ci.nsIIOService)
                .newURI(aUrl, null, null);
    return url.scheme == "http" || url.scheme == "https";
  },

  // nsIPresentationLocalDevice
  get windowId() { return this._id; },

  // nsIObserver
  observe: function CRDD_observe(aSubject, aTopic, aData) {
    if (aTopic == TOPIC_PRESENTATION_VIEW_READY) {
      log("ChromecastRemoteDisplayDevice - observe: aTopic="
          + aTopic + " data=" + aData);
      if (this.windowId === aData) {
        Services.obs.removeObserver(this, TOPIC_PRESENTATION_VIEW_READY);
        this._ctrlChannel.notifyConnected();
      }
    }
  },

  QueryInterface: XPCOMUtils.generateQI([Ci.nsIPresentationDevice,
                                         Ci.nsIPresentationLocalDevice,
                                         Ci.nsISupportsWeakReference,
                                         Ci.nsIObserver]),
};

function AndroidCastDeviceProvider() {
}

AndroidCastDeviceProvider.prototype = {
  _listener: null,
  _deviceList: new Map(),

  onSessionRequest: function APDP_onSessionRequest(aDeviceId,
                                                   aUrl,
                                                   aPresentationId,
                                                   aControlChannel) {
    log("AndroidCastDeviceProvider - onSessionRequest"
        + " aDeviceId=" + aDeviceId);
    let device = this._deviceList.get(aDeviceId);
    let receiverDevice = new ChromecastRemoteDisplayDevice(this,
                                                           device.id,
                                                           device.name,
                                                           Ci.nsIPresentationService.ROLE_RECEIVER);
    this._listener.onSessionRequest(receiverDevice,
                                    aUrl,
                                    aPresentationId,
                                    aControlChannel);
  },

  onTerminateRequest: function APDP_onTerminateRequest(aDeviceId,
                                                       aPresentationId,
                                                       aControlChannel,
                                                       aIsFromReceiver) {
    log("AndroidCastDeviceProvider - onTerminateRequest"
        + " aDeviceId=" + aDeviceId
        + " aPresentationId=" + aPresentationId
        + " aIsFromReceiver=" + aIsFromReceiver);
    let device = this._deviceList.get(aDeviceId);
    this._listener.onTerminateRequest(device,
                                      aPresentationId,
                                      aControlChannel,
                                      aIsFromReceiver);
  },

  // nsIPresentationDeviceProvider
  set listener(aListener) {
    this._listener = aListener;

    // When unload this provider.
    if (!this._listener) {
      // remove observer
      Services.obs.removeObserver(this, TOPIC_ANDROID_CAST_DEVICE_ADDED);
      Services.obs.removeObserver(this, TOPIC_ANDROID_CAST_DEVICE_REMOVED);
      return;
    }

    // Sync all device already found by Android.
    Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_SYNCDEVICE, "");
    // Observer registration
    Services.obs.addObserver(this, TOPIC_ANDROID_CAST_DEVICE_ADDED, false);
    Services.obs.addObserver(this, TOPIC_ANDROID_CAST_DEVICE_REMOVED, false);
  },

  get listener() {
    return this._listener;
  },

  forceDiscovery: function APDP_forceDiscovery() {
    // There is no API to do force discovery in Android SDK.
  },

  // nsIObserver
  observe: function APDP_observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case TOPIC_ANDROID_CAST_DEVICE_ADDED: {
        let deviceInfo = JSON.parse(aData);
        let deviceId   = deviceInfo.uuid;

        if (!this._deviceList.has(deviceId)) {
          let device = new ChromecastRemoteDisplayDevice(this,
                                                         deviceInfo.uuid,
                                                         deviceInfo.friendlyName,
                                                         Ci.nsIPresentationService.ROLE_CONTROLLER);
          this._deviceList.set(device.id, device);
          this._listener.addDevice(device);
        } else {
          let device = this._deviceList.get(deviceId);
          device.update(deviceInfo.friendlyName);
          this._listener.updateDevice(device);
        }
        break;
      }
      case TOPIC_ANDROID_CAST_DEVICE_REMOVED: {
        let deviceId = aData;
        let device   = this._deviceList.get(deviceId);
        this._listener.removeDevice(device);
        this._deviceList.delete(deviceId);
        break;
      }
    }
  },

  classID: Components.ID("{7394f24c-dbc3-48c8-8a47-cd10169b7c6b}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsIPresentationDeviceProvider]),
};

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([AndroidCastDeviceProvider]);