/*
 * libjingle
 * Copyright 2014 Google Inc.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  1. Redistributions of source code must retain the above copyright notice,
 *     this list of conditions and the following disclaimer.
 *  2. 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.
 *  3. The name of the author may not be used to endorse or promote products
 *     derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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 org.appspot.apprtc;

import org.appspot.apprtc.AppRTCClient.SignalingParameters;
import org.appspot.apprtc.util.LooperExecutor;

import android.content.Context;
import android.opengl.EGLContext;
import android.util.Log;

import org.webrtc.DataChannel;
import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints;
import org.webrtc.MediaStream;
import org.webrtc.MediaStreamTrack;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnection.IceConnectionState;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription;
import org.webrtc.StatsObserver;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoRenderer;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;

import java.util.LinkedList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Peer connection client implementation.
 *
 * <p>All public methods are routed to local looper thread.
 * All PeerConnectionEvents callbacks are invoked from the same looper thread.
 */
public class PeerConnectionClient {
  private static final String TAG = "PCRTCClient";
  private static final boolean PREFER_ISAC = false;
  public static final String VIDEO_CODEC_VP8 = "VP8";
  public static final String VIDEO_CODEC_VP9 = "VP9";
  private static final String FIELD_TRIAL_VP9 = "WebRTC-SupportVP9/Enabled/";
  public static final String VIDEO_TRACK_ID = "ARDAMSv0";
  public static final String AUDIO_TRACK_ID = "ARDAMSa0";

  private final LooperExecutor executor;
  private PeerConnectionFactory factory = null;
  private PeerConnection pc = null;
  private VideoSource videoSource;
  private boolean videoSourceStopped = false;
  private boolean isError = false;
  private final PCObserver pcObserver = new PCObserver();
  private final SDPObserver sdpObserver = new SDPObserver();
  private VideoRenderer.Callbacks localRender;
  private VideoRenderer.Callbacks remoteRender;
  private SignalingParameters signalingParameters;
  // Queued remote ICE candidates are consumed only after both local and
  // remote descriptions are set. Similarly local ICE candidates are sent to
  // remote peer after both local and remote description are set.
  private LinkedList<IceCandidate> queuedRemoteCandidates = null;
  private MediaConstraints sdpMediaConstraints;
  private PeerConnectionEvents events;
  private int startBitrate;
  private boolean isInitiator;
  private boolean useFrontFacingCamera = true;
  private SessionDescription localSdp = null; // either offer or answer SDP
  private MediaStream mediaStream = null;

  /**
   * SDP/ICE ready callbacks.
   */
  public static interface PeerConnectionEvents {
    /**
     * Callback fired once local SDP is created and set.
     */
    public void onLocalDescription(final SessionDescription sdp);

    /**
     * Callback fired once local Ice candidate is generated.
     */
    public void onIceCandidate(final IceCandidate candidate);

    /**
     * Callback fired once connection is established (IceConnectionState is
     * CONNECTED).
     */
    public void onIceConnected();

    /**
     * Callback fired once connection is closed (IceConnectionState is
     * DISCONNECTED).
     */
    public void onIceDisconnected();

    /**
     * Callback fired once peer connection is closed.
     */
    public void onPeerConnectionClosed();

    /**
     * Callback fired once peer connection error happened.
     */
    public void onPeerConnectionError(final String description);

  }

  public PeerConnectionClient() {
    executor = new LooperExecutor();
  }

  public void createPeerConnectionFactory(
      final Context context,
      final String videoCodec,
      final boolean videoCodecHwAcceleration,
      final EGLContext renderEGLContext,
      final PeerConnectionEvents events) {
    this.events = events;
    executor.requestStart();
    executor.execute(new Runnable() {
      @Override
      public void run() {
        createPeerConnectionFactoryInternal(
            context, videoCodec, videoCodecHwAcceleration, renderEGLContext);
      }
    });
  }

  public void createPeerConnection(
      final VideoRenderer.Callbacks localRender,
      final VideoRenderer.Callbacks remoteRender,
      final SignalingParameters signalingParameters,
      final int startBitrate) {
    this.localRender = localRender;
    this.remoteRender = remoteRender;
    this.signalingParameters = signalingParameters;
    this.startBitrate = startBitrate;
    executor.execute(new Runnable() {
      @Override
      public void run() {
        createPeerConnectionInternal();
      }
    });
  }

  public void close() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        closeInternal();
      }
    });
    executor.requestStop();
  }

  private void createPeerConnectionFactoryInternal(
      Context context,
      String videoCodec,
      boolean videoCodecHwAcceleration,
      EGLContext renderEGLContext) {
    Log.d(TAG, "Create peer connection factory with EGLContext "
      + renderEGLContext);
    isError = false;
    if (videoCodec.equals(VIDEO_CODEC_VP9)) {
      PeerConnectionFactory.initializeFieldTrials(FIELD_TRIAL_VP9);
    } else {
      PeerConnectionFactory.initializeFieldTrials(null);
    }
    if (!PeerConnectionFactory.initializeAndroidGlobals(
        context, true, true, videoCodecHwAcceleration, renderEGLContext)) {
      events.onPeerConnectionError("Failed to initializeAndroidGlobals");
    }
    factory = new PeerConnectionFactory();
    Log.d(TAG, "Peer connection factory created.");
  }

  private void createPeerConnectionInternal() {
    if (factory == null || isError) {
      Log.e(TAG, "Peerconnection factory is not created");
      return;
    }
    Log.d(TAG, "Create peer connection.");
    isInitiator = signalingParameters.initiator;
    queuedRemoteCandidates = new LinkedList<IceCandidate>();

    sdpMediaConstraints = new MediaConstraints();
    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
        "OfferToReceiveAudio", "true"));
    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
        "OfferToReceiveVideo", "true"));

    MediaConstraints pcConstraints = signalingParameters.pcConstraints;
    pcConstraints.optional.add(
        new MediaConstraints.KeyValuePair("RtpDataChannels", "true"));
    pc = factory.createPeerConnection(signalingParameters.iceServers,
        pcConstraints, pcObserver);
    isInitiator = false;

    // Uncomment to get ALL WebRTC tracing and SENSITIVE libjingle logging.
    // NOTE: this _must_ happen while |factory| is alive!
    // Logging.enableTracing(
    //     "logcat:",
    //     EnumSet.of(Logging.TraceLevel.TRACE_ALL),
    //     Logging.Severity.LS_SENSITIVE);

    mediaStream = factory.createLocalMediaStream("ARDAMS");
    if (signalingParameters.videoConstraints != null) {
      mediaStream.addTrack(createVideoTrack(useFrontFacingCamera));
    }

    if (signalingParameters.audioConstraints != null) {
      mediaStream.addTrack(factory.createAudioTrack(
          AUDIO_TRACK_ID,
          factory.createAudioSource(signalingParameters.audioConstraints)));
    }
    pc.addStream(mediaStream);
    Log.d(TAG, "Peer connection created.");
  }

  private void closeInternal() {
    Log.d(TAG, "Closing peer connection.");
    if (pc != null) {
      pc.dispose();
      pc = null;
    }
    if (videoSource != null) {
      videoSource.dispose();
      videoSource = null;
    }
    Log.d(TAG, "Closing peer connection factory.");
    if (factory != null) {
      factory.dispose();
      factory = null;
    }
    Log.d(TAG, "Closing peer connection done.");
    events.onPeerConnectionClosed();
  }

  public boolean getStats(StatsObserver observer, MediaStreamTrack track) {
    if (pc != null && !isError) {
      return pc.getStats(observer, track);
    } else {
      return false;
    }
  }

  public void createOffer() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (pc != null && !isError) {
          Log.d(TAG, "PC Create OFFER");
          isInitiator = true;
          pc.createOffer(sdpObserver, sdpMediaConstraints);
        }
      }
    });
  }

  public void createAnswer() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (pc != null && !isError) {
          Log.d(TAG, "PC create ANSWER");
          isInitiator = false;
          pc.createAnswer(sdpObserver, sdpMediaConstraints);
        }
      }
    });
  }

  public void addRemoteIceCandidate(final IceCandidate candidate) {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (pc != null && !isError) {
          if (queuedRemoteCandidates != null) {
            queuedRemoteCandidates.add(candidate);
          } else {
            pc.addIceCandidate(candidate);
          }
        }
      }
    });
  }

  public void setRemoteDescription(final SessionDescription sdp) {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (pc == null || isError) {
          return;
        }
        String sdpDescription = sdp.description;
        if (PREFER_ISAC) {
          sdpDescription = preferISAC(sdpDescription);
        }
        if (startBitrate > 0) {
          sdpDescription = setStartBitrate(sdpDescription, startBitrate);
        }
        Log.d(TAG, "Set remote SDP.");
        SessionDescription sdpRemote = new SessionDescription(
            sdp.type, sdpDescription);
        pc.setRemoteDescription(sdpObserver, sdpRemote);
      }
    });
  }

  public void stopVideoSource() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (videoSource != null && !videoSourceStopped) {
          Log.d(TAG, "Stop video source.");
          videoSource.stop();
          videoSourceStopped = true;
        }
      }
    });
  }

  public void startVideoSource() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (videoSource != null && videoSourceStopped) {
          Log.d(TAG, "Restart video source.");
          videoSource.restart();
          videoSourceStopped = false;
        }
      }
    });
  }

  private void reportError(final String errorMessage) {
    Log.e(TAG, "Peerconnection error: " + errorMessage);
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (!isError) {
          events.onPeerConnectionError(errorMessage);
          isError = true;
        }
      }
    });
  }

  // Cycle through likely device names for the camera and return the first
  // capturer that works, or crash if none do.
  private VideoCapturer getVideoCapturer(boolean useFrontFacing) {
    String[] cameraFacing = { "front", "back" };
    if (!useFrontFacing) {
      cameraFacing[0] = "back";
      cameraFacing[1] = "front";
    }
    for (String facing : cameraFacing) {
      int[] cameraIndex = { 0, 1 };
      int[] cameraOrientation = { 0, 90, 180, 270 };
      for (int index : cameraIndex) {
        for (int orientation : cameraOrientation) {
          String name = "Camera " + index + ", Facing " + facing
              + ", Orientation " + orientation;
          VideoCapturer capturer = VideoCapturer.create(name);
          if (capturer != null) {
            Log.d(TAG, "Using camera: " + name);
            return capturer;
          }
        }
      }
    }
    reportError("Failed to open capturer");
    return null;
  }

  private VideoTrack createVideoTrack(boolean frontFacing) {
    VideoCapturer capturer = getVideoCapturer(frontFacing);
    if (videoSource != null) {
      videoSource.stop();
      videoSource.dispose();
    }

    videoSource = factory.createVideoSource(
        capturer, signalingParameters.videoConstraints);
    String trackExtension = frontFacing ? "frontFacing" : "backFacing";
    VideoTrack videoTrack =
        factory.createVideoTrack(VIDEO_TRACK_ID + trackExtension, videoSource);
    videoTrack.addRenderer(new VideoRenderer(localRender));
    return videoTrack;
  }

  private static String setStartBitrate(
      String sdpDescription, int bitrateKbps) {
    String[] lines = sdpDescription.split("\r\n");
    int lineIndex = -1;
    String vp8RtpMap = null;
    Pattern vp8Pattern =
        Pattern.compile("^a=rtpmap:(\\d+) VP8/90000[\r]?$");
    for (int i = 0; i < lines.length; i++) {
      Matcher vp8Matcher = vp8Pattern.matcher(lines[i]);
      if (vp8Matcher.matches()) {
        vp8RtpMap = vp8Matcher.group(1);
        lineIndex = i;
        break;
      }
    }
    if (vp8RtpMap == null) {
      Log.e(TAG, "No rtpmap for VP8 codec");
      return sdpDescription;
    }
    Log.d(TAG, "Found rtpmap " + vp8RtpMap + " at " + lines[lineIndex]);
    StringBuilder newSdpDescription = new StringBuilder();
    for (int i = 0; i < lines.length; i++) {
      newSdpDescription.append(lines[i]).append("\r\n");
      if (i == lineIndex) {
        String bitrateSet = "a=fmtp:" + vp8RtpMap
            + " x-google-start-bitrate=" + bitrateKbps;
        Log.d(TAG, "Add remote SDP line: " + bitrateSet);
        newSdpDescription.append(bitrateSet).append("\r\n");
      }
    }
    return newSdpDescription.toString();
  }

  // Mangle SDP to prefer ISAC/16000 over any other audio codec.
  private static String preferISAC(String sdpDescription) {
    String[] lines = sdpDescription.split("\r\n");
    int mLineIndex = -1;
    String isac16kRtpMap = null;
    Pattern isac16kPattern =
        Pattern.compile("^a=rtpmap:(\\d+) ISAC/16000[\r]?$");
    for (int i = 0;
         (i < lines.length) && (mLineIndex == -1 || isac16kRtpMap == null);
         ++i) {
      if (lines[i].startsWith("m=audio ")) {
        mLineIndex = i;
        continue;
      }
      Matcher isac16kMatcher = isac16kPattern.matcher(lines[i]);
      if (isac16kMatcher.matches()) {
        isac16kRtpMap = isac16kMatcher.group(1);
        continue;
      }
    }
    if (mLineIndex == -1) {
      Log.d(TAG, "No m=audio line, so can't prefer iSAC");
      return sdpDescription;
    }
    if (isac16kRtpMap == null) {
      Log.d(TAG, "No ISAC/16000 line, so can't prefer iSAC");
      return sdpDescription;
    }
    String[] origMLineParts = lines[mLineIndex].split(" ");
    StringBuilder newMLine = new StringBuilder();
    int origPartIndex = 0;
    // Format is: m=<media> <port> <proto> <fmt> ...
    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
    newMLine.append(isac16kRtpMap);
    for (; origPartIndex < origMLineParts.length; ++origPartIndex) {
      if (!origMLineParts[origPartIndex].equals(isac16kRtpMap)) {
        newMLine.append(" ").append(origMLineParts[origPartIndex]);
      }
    }
    lines[mLineIndex] = newMLine.toString();
    StringBuilder newSdpDescription = new StringBuilder();
    for (String line : lines) {
      newSdpDescription.append(line).append("\r\n");
    }
    return newSdpDescription.toString();
  }

  private void drainCandidates() {
    if (queuedRemoteCandidates != null) {
      Log.d(TAG, "Add " + queuedRemoteCandidates.size() + " remote candidates");
      for (IceCandidate candidate : queuedRemoteCandidates) {
        pc.addIceCandidate(candidate);
      }
      queuedRemoteCandidates = null;
    }
  }

  private void switchCameraInternal() {
    if (signalingParameters.videoConstraints == null) {
      return;  // No video is sent.
    }
    if (pc.signalingState() != PeerConnection.SignalingState.STABLE) {
      Log.e(TAG, "Switching camera during negotiation is not handled.");
      return;
    }

    Log.d(TAG, "Switch camera");
    pc.removeStream(mediaStream);
    VideoTrack currentTrack = mediaStream.videoTracks.get(0);
    mediaStream.removeTrack(currentTrack);

    String trackId = currentTrack.id();
    // On Android, there can only be one camera open at the time and we
    // need to release our implicit references to the videoSource before the
    // PeerConnectionFactory is released. Since createVideoTrack creates a new
    // videoSource and frees the old one, we need to release the track here.
    currentTrack.dispose();

    useFrontFacingCamera = !useFrontFacingCamera;
    VideoTrack newTrack = createVideoTrack(useFrontFacingCamera);
    mediaStream.addTrack(newTrack);
    pc.addStream(mediaStream);

    SessionDescription remoteDesc = pc.getRemoteDescription();
    if (localSdp == null || remoteDesc == null) {
      Log.d(TAG, "Switching camera before the negotiation started.");
      return;
    }

    localSdp = new SessionDescription(localSdp.type,
        localSdp.description.replaceAll(trackId, newTrack.id()));

    if (isInitiator) {
      pc.setLocalDescription(new SwitchCameraSdbObserver(), localSdp);
      pc.setRemoteDescription(new SwitchCameraSdbObserver(), remoteDesc);
    } else {
      pc.setRemoteDescription(new SwitchCameraSdbObserver(), remoteDesc);
      pc.setLocalDescription(new SwitchCameraSdbObserver(), localSdp);
    }
    Log.d(TAG, "Switch camera done");
  }

  public void switchCamera() {
    executor.execute(new Runnable() {
      @Override
      public void run() {
        if (pc != null && !isError) {
          switchCameraInternal();
        }
      }
    });
  }

  // Implementation detail: observe ICE & stream changes and react accordingly.
  private class PCObserver implements PeerConnection.Observer {
    @Override
    public void onIceCandidate(final IceCandidate candidate){
      executor.execute(new Runnable() {
        @Override
        public void run() {
          events.onIceCandidate(candidate);
        }
      });
    }

    @Override
    public void onSignalingChange(
        PeerConnection.SignalingState newState) {
      Log.d(TAG, "SignalingState: " + newState);
    }

    @Override
    public void onIceConnectionChange(
        final PeerConnection.IceConnectionState newState) {
      executor.execute(new Runnable() {
        @Override
        public void run() {
          Log.d(TAG, "IceConnectionState: " + newState);
          if (newState == IceConnectionState.CONNECTED) {
            events.onIceConnected();
          } else if (newState == IceConnectionState.DISCONNECTED) {
            events.onIceDisconnected();
          } else if (newState == IceConnectionState.FAILED) {
            reportError("ICE connection failed.");
          }
        }
      });
    }

    @Override
    public void onIceGatheringChange(
      PeerConnection.IceGatheringState newState) {
      Log.d(TAG, "IceGatheringState: " + newState);
    }

    @Override
    public void onAddStream(final MediaStream stream){
      executor.execute(new Runnable() {
        @Override
        public void run() {
          if (pc == null || isError) {
            return;
          }
          if (stream.audioTracks.size() > 1 || stream.videoTracks.size() > 1) {
            reportError("Weird-looking stream: " + stream);
            return;
          }
          if (stream.videoTracks.size() == 1) {
            stream.videoTracks.get(0).addRenderer(
                new VideoRenderer(remoteRender));
          }
        }
      });
    }

    @Override
    public void onRemoveStream(final MediaStream stream){
      executor.execute(new Runnable() {
        @Override
        public void run() {
          if (pc == null || isError) {
            return;
          }
          stream.videoTracks.get(0).dispose();
        }
      });
    }

    @Override
    public void onDataChannel(final DataChannel dc) {
      reportError("AppRTC doesn't use data channels, but got: " + dc.label()
          + " anyway!");
    }

    @Override
    public void onRenegotiationNeeded() {
      // No need to do anything; AppRTC follows a pre-agreed-upon
      // signaling/negotiation protocol.
    }
  }

  // Implementation detail: handle offer creation/signaling and answer setting,
  // as well as adding remote ICE candidates once the answer SDP is set.
  private class SDPObserver implements SdpObserver {
    @Override
    public void onCreateSuccess(final SessionDescription origSdp) {
      if (localSdp != null) {
        reportError("Multiple SDP create.");
        return;
      }
      String sdpDescription = origSdp.description;
      if (PREFER_ISAC) {
        sdpDescription = preferISAC(sdpDescription);
      }
      final SessionDescription sdp = new SessionDescription(
          origSdp.type, sdpDescription);
      localSdp = sdp;
      executor.execute(new Runnable() {
        @Override
        public void run() {
          if (pc != null && !isError) {
            Log.d(TAG, "Set local SDP from " + sdp.type);
            pc.setLocalDescription(sdpObserver, sdp);
          }
        }
      });
    }

    @Override
    public void onSetSuccess() {
      executor.execute(new Runnable() {
        @Override
        public void run() {
          if (pc == null || isError) {
            return;
          }
          if (isInitiator) {
            // For offering peer connection we first create offer and set
            // local SDP, then after receiving answer set remote SDP.
            if (pc.getRemoteDescription() == null) {
              // We've just set our local SDP so time to send it.
              Log.d(TAG, "Local SDP set succesfully");
              events.onLocalDescription(localSdp);
            } else {
              // We've just set remote description, so drain remote
              // and send local ICE candidates.
              Log.d(TAG, "Remote SDP set succesfully");
              drainCandidates();
            }
          } else {
            // For answering peer connection we set remote SDP and then
            // create answer and set local SDP.
            if (pc.getLocalDescription() != null) {
              // We've just set our local SDP so time to send it, drain
              // remote and send local ICE candidates.
              Log.d(TAG, "Local SDP set succesfully");
              events.onLocalDescription(localSdp);
              drainCandidates();
            } else {
              // We've just set remote SDP - do nothing for now -
              // answer will be created soon.
              Log.d(TAG, "Remote SDP set succesfully");
            }
          }
        }
      });
    }

    @Override
    public void onCreateFailure(final String error) {
      reportError("createSDP error: " + error);
    }

    @Override
    public void onSetFailure(final String error) {
      reportError("setSDP error: " + error);
    }
  }

  private class SwitchCameraSdbObserver implements SdpObserver {
    @Override
    public void onCreateSuccess(SessionDescription sdp) {
    }

    @Override
    public void onSetSuccess() {
      Log.d(TAG, "Camera switch SDP set succesfully");
    }

    @Override
    public void onCreateFailure(final String error) {
    }

    @Override
    public void onSetFailure(final String error) {
      reportError("setSDP error while switching camera: " + error);
    }
  }
}
