/*
 * (C) Copyright 2017-2018 OpenVidu (https://openvidu.io/)
 *
 * 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 io.openvidu.java.client;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import javax.net.ssl.SSLContext;

import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.TrustStrategy;
import org.apache.http.util.EntityUtils;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OpenVidu {

	private static final Logger log = LoggerFactory.getLogger(OpenVidu.class);

	private String secret;
	protected static String urlOpenViduServer;
	protected static HttpClient httpClient;
	protected static Map<String, Session> activeSessions = new ConcurrentHashMap<>();

	protected final static String API_SESSIONS = "api/sessions";
	protected final static String API_TOKENS = "api/tokens";
	protected final static String API_RECORDINGS = "api/recordings";
	protected final static String API_RECORDINGS_START = "/start";
	protected final static String API_RECORDINGS_STOP = "/stop";

	/**
	 * @param urlOpenViduServer
	 *            Public accessible IP where your instance of OpenVidu Server is up
	 *            an running
	 * @param secret
	 *            Secret used on OpenVidu Server initialization
	 */
	public OpenVidu(String urlOpenViduServer, String secret) {

		OpenVidu.urlOpenViduServer = urlOpenViduServer;

		if (!OpenVidu.urlOpenViduServer.endsWith("/")) {
			OpenVidu.urlOpenViduServer += "/";
		}

		this.secret = secret;

		TrustStrategy trustStrategy = new TrustStrategy() {
			@Override
			public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException {
				return true;
			}
		};

		CredentialsProvider provider = new BasicCredentialsProvider();
		UsernamePasswordCredentials credentials = new UsernamePasswordCredentials("OPENVIDUAPP", this.secret);
		provider.setCredentials(AuthScope.ANY, credentials);

		SSLContext sslContext;

		try {
			sslContext = new SSLContextBuilder().loadTrustMaterial(null, trustStrategy).build();
		} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
			throw new RuntimeException(e);
		}

		RequestConfig.Builder requestBuilder = RequestConfig.custom();
		requestBuilder = requestBuilder.setConnectTimeout(30000);
		requestBuilder = requestBuilder.setConnectionRequestTimeout(30000);

		OpenVidu.httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestBuilder.build())
				.setConnectionTimeToLive(30, TimeUnit.SECONDS).setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
				.setSSLContext(sslContext).setDefaultCredentialsProvider(provider).build();
	}

	/**
	 * Creates an OpenVidu session with the default settings
	 * 
	 * @return The created session
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 */
	public Session createSession() throws OpenViduJavaClientException, OpenViduHttpException {
		Session s = new Session();
		OpenVidu.activeSessions.put(s.getSessionId(), s);
		return s;
	}

	/**
	 * Creates an OpenVidu session
	 * 
	 * @param properties
	 *            The specific configuration for this session
	 * 
	 * @return The created session
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>409</code>: you are trying to assign an already-in-use
	 *             custom sessionId to the session. See
	 *             {@link io.openvidu.java.client.SessionProperties#customSessionId()}</li>
	 *             </ul>
	 */
	public Session createSession(SessionProperties properties)
			throws OpenViduJavaClientException, OpenViduHttpException {
		Session s = new Session(properties);
		OpenVidu.activeSessions.put(s.getSessionId(), s);
		return s;
	}

	/**
	 * Starts the recording of a {@link io.openvidu.java.client.Session}
	 *
	 * @param sessionId
	 *            The sessionId of the session you want to start recording
	 * @param properties
	 *            The configuration for this recording
	 *
	 * @return The new created session
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no session exists for the passed
	 *             <i>sessionId</i></li>
	 *             <li><code>400</code>: the session has no connected
	 *             participants</li>
	 *             <li><code>409</code>: the session is not configured for using
	 *             {@link io.openvidu.java.client.MediaMode#ROUTED} or it is already
	 *             being recorded</li>
	 *             <li><code>501</code>: OpenVidu Server recording module is
	 *             disabled (<i>openvidu.recording</i> property set to
	 *             <i>false</i>)</li>
	 *             </ul>
	 */
	@SuppressWarnings("unchecked")
	public Recording startRecording(String sessionId, RecordingProperties properties)
			throws OpenViduJavaClientException, OpenViduHttpException {

		HttpPost request = new HttpPost(OpenVidu.urlOpenViduServer + API_RECORDINGS + API_RECORDINGS_START);

		JSONObject json = new JSONObject();
		json.put("session", sessionId);
		json.put("name", properties.name());
		json.put("outputMode", properties.outputMode());

		if (Recording.OutputMode.COMPOSED.equals(properties.outputMode())) {
			json.put("recordingLayout",
					(properties.recordingLayout() != null) ? properties.recordingLayout().name() : "");
			if (RecordingLayout.CUSTOM.equals(properties.recordingLayout())) {
				json.put("customLayout", (properties.customLayout() != null) ? properties.customLayout() : "");
			}
		}

		StringEntity params = null;
		try {
			params = new StringEntity(json.toString());
		} catch (UnsupportedEncodingException e1) {
			throw new OpenViduJavaClientException(e1.getMessage(), e1.getCause());
		}

		request.setHeader(HttpHeaders.CONTENT_TYPE, "application/json");
		request.setEntity(params);

		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e2) {
			throw new OpenViduJavaClientException(e2.getMessage(), e2.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if ((statusCode == org.apache.http.HttpStatus.SC_OK)) {
				Recording r = new Recording(httpResponseToJson(response));
				Session activeSession = OpenVidu.activeSessions.get(r.getSessionId());
				if (activeSession != null) {
					activeSession.setIsBeingRecorded(true);
				} else {
					log.warn("No active session found for sessionId '" + r.getSessionId()
							+ "'. This instance of OpenVidu Java Client didn't create this session");
				}
				return r;
			} else {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	/**
	 * Starts the recording of a {@link io.openvidu.java.client.Session}
	 *
	 * @param sessionId
	 *            The sessionId of the session you want to start recording
	 * @param name
	 *            The name you want to give to the video file. You can access this
	 *            same value in your clients on recording events (recordingStarted,
	 *            recordingStopped). <strong>WARNING: this parameter follows an
	 *            overwriting policy.</strong> If you name two recordings the same,
	 *            the newest MP4 file will overwrite the oldest one
	 *
	 * @return The started recording. If this method successfully returns the
	 *         Recording object it means that the recording can be stopped with
	 *         guarantees
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no session exists for the passed
	 *             <i>sessionId</i></li>
	 *             <li><code>400</code>: the session has no connected
	 *             participants</li>
	 *             <li><code>409</code>: the session is not configured for using
	 *             {@link io.openvidu.java.client.MediaMode#ROUTED} or it is already
	 *             being recorded</li>
	 *             <li><code>501</code>: OpenVidu Server recording module is
	 *             disabled (<i>openvidu.recording</i> property set to
	 *             <i>false</i>)</li>
	 *             </ul>
	 */
	public Recording startRecording(String sessionId, String name)
			throws OpenViduJavaClientException, OpenViduHttpException {
		if (name == null) {
			name = "";
		}
		return this.startRecording(sessionId, new RecordingProperties.Builder().name(name).build());
	}

	/**
	 * Starts the recording of a {@link io.openvidu.java.client.Session}
	 *
	 * @param sessionId
	 *            The sessionId of the session you want to start recording
	 *
	 * @return The started recording. If this method successfully returns the
	 *         Recording object it means that the recording can be stopped with
	 *         guarantees
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no session exists for the passed
	 *             <i>sessionId</i></li>
	 *             <li><code>400</code>: the session has no connected
	 *             participants</li>
	 *             <li><code>409</code>: the session is not configured for using
	 *             {@link io.openvidu.java.client.MediaMode#ROUTED} or it is already
	 *             being recorded</li>
	 *             <li><code>501</code>: OpenVidu Server recording module is
	 *             disabled (<i>openvidu.recording</i> property set to
	 *             <i>false</i>)</li>
	 *             </ul>
	 */
	public Recording startRecording(String sessionId) throws OpenViduJavaClientException, OpenViduHttpException {
		return this.startRecording(sessionId, "");
	}

	/**
	 * Stops the recording of a {@link io.openvidu.java.client.Session}
	 *
	 * @param recordingId
	 *            The id property of the recording you want to stop
	 *
	 * @return The stopped recording
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no recording exists for the passed
	 *             <i>recordingId</i></li>
	 *             <li><code>406</code>: recording has <i>starting</i> status. Wait
	 *             until <i>started</i> status before stopping the recording</li>
	 *             </ul>
	 */
	public Recording stopRecording(String recordingId) throws OpenViduJavaClientException, OpenViduHttpException {
		HttpPost request = new HttpPost(
				OpenVidu.urlOpenViduServer + API_RECORDINGS + API_RECORDINGS_STOP + "/" + recordingId);
		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if ((statusCode == org.apache.http.HttpStatus.SC_OK)) {
				Recording r = new Recording(httpResponseToJson(response));
				Session activeSession = OpenVidu.activeSessions.get(r.getSessionId());
				if (activeSession != null) {
					activeSession.setIsBeingRecorded(false);
				} else {
					log.warn("No active session found for sessionId '" + r.getSessionId()
							+ "'. This instance of OpenVidu Java Client didn't create this session");
				}
				return r;
			} else {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	/**
	 * Gets an existing recording
	 *
	 * @param recordingId
	 *            The id property of the recording you want to retrieve
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no recording exists for the passed
	 *             <i>recordingId</i></li>
	 *             </ul>
	 */
	public Recording getRecording(String recordingId) throws OpenViduJavaClientException, OpenViduHttpException {
		HttpGet request = new HttpGet(OpenVidu.urlOpenViduServer + API_RECORDINGS + "/" + recordingId);
		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if ((statusCode == org.apache.http.HttpStatus.SC_OK)) {
				return new Recording(httpResponseToJson(response));
			} else {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	/**
	 * Lists all existing recordings
	 *
	 * @return A {@link java.util.List} with all existing recordings
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 */
	@SuppressWarnings("unchecked")
	public List<Recording> listRecordings() throws OpenViduJavaClientException, OpenViduHttpException {
		HttpGet request = new HttpGet(OpenVidu.urlOpenViduServer + API_RECORDINGS);
		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if ((statusCode == org.apache.http.HttpStatus.SC_OK)) {
				List<Recording> recordings = new ArrayList<>();
				JSONObject json = httpResponseToJson(response);
				JSONArray array = (JSONArray) json.get("items");
				array.forEach(item -> {
					recordings.add(new Recording((JSONObject) item));
				});
				return recordings;
			} else {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	/**
	 * Deletes a recording. The recording must have status
	 * {@link io.openvidu.java.client.Recording.Status#stopped} or
	 * {@link io.openvidu.java.client.Recording.Status#available}
	 *
	 * @param recordingId
	 *            The id property of the recording you want to delete
	 * 
	 * @throws OpenViduJavaClientException
	 * @throws OpenViduHttpException
	 *             Value returned from
	 *             {@link io.openvidu.java.client.OpenViduHttpException#getStatus()}
	 *             <ul>
	 *             <li><code>404</code>: no recording exists for the passed
	 *             <i>recordingId</i></li>
	 *             <li><code>409</code>: the recording has <i>started</i> status.
	 *             Stop it before deletion</li>
	 *             </ul>
	 */
	public void deleteRecording(String recordingId) throws OpenViduJavaClientException, OpenViduHttpException {
		HttpDelete request = new HttpDelete(OpenVidu.urlOpenViduServer + API_RECORDINGS + "/" + recordingId);
		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if (!(statusCode == org.apache.http.HttpStatus.SC_NO_CONTENT)) {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	/**
	 * Returns the list of active sessions. <strong>This value will remain unchanged
	 * since the last time method {@link io.openvidu.java.client.OpenVidu#fetch()}
	 * was called</strong>. Exceptions to this rule are:
	 * <ul>
	 * <li>Calling {@link io.openvidu.java.client.Session#fetch()} updates that
	 * specific Session status</li>
	 * <li>Calling {@link io.openvidu.java.client.Session#close()} automatically
	 * removes the Session from the list of active Sessions</li>
	 * <li>Calling
	 * {@link io.openvidu.java.client.Session#forceDisconnect(Connection)}
	 * automatically updates the inner affected connections for that specific
	 * Session</li>
	 * <li>Calling {@link io.openvidu.java.client.Session#forceUnpublish(Publisher)}
	 * also automatically updates the inner affected connections for that specific
	 * Session</li>
	 * <li>Calling {@link io.openvidu.java.client.OpenVidu#startRecording(String)}
	 * and {@link io.openvidu.java.client.OpenVidu#stopRecording(String)}
	 * automatically updates the recording status of the Session
	 * ({@link io.openvidu.java.client.Session#isBeingRecorded()})</li>
	 * </ul>
	 * <br>
	 * To get the list of active sessions with their current actual value, you must
	 * call first {@link io.openvidu.java.client.OpenVidu#fetch()} and then
	 * {@link io.openvidu.java.client.OpenVidu#getActiveSessions()}
	 */
	public List<Session> getActiveSessions() {
		return new ArrayList<>(OpenVidu.activeSessions.values());
	}

	/**
	 * Updates every property of every active Session with the current status they
	 * have in OpenVidu Server. After calling this method you can access the updated
	 * list of active sessions by calling
	 * {@link io.openvidu.java.client.OpenVidu#getActiveSessions()}
	 * 
	 * @return true if any Session status has changed with respect to the server,
	 *         false if not. This applies to any property or sub-property of any of
	 *         the sessions locally stored in OpenVidu Java Client
	 * 
	 * @throws OpenViduHttpException
	 * @throws OpenViduJavaClientException
	 */
	@SuppressWarnings("unchecked")
	public boolean fetch() throws OpenViduJavaClientException, OpenViduHttpException {
		HttpGet request = new HttpGet(OpenVidu.urlOpenViduServer + API_SESSIONS);

		HttpResponse response;
		try {
			response = OpenVidu.httpClient.execute(request);
		} catch (IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}

		try {
			int statusCode = response.getStatusLine().getStatusCode();
			if ((statusCode == org.apache.http.HttpStatus.SC_OK)) {
				JSONObject jsonSessions = httpResponseToJson(response);
				JSONArray jsonArraySessions = (JSONArray) jsonSessions.get("content");

				// Set to store fetched sessionIds and later remove closed sessions
				Set<String> fetchedSessionIds = new HashSet<>();
				// Boolean to store if any Session has changed
				final boolean[] hasChanged = { false };
				jsonArraySessions.forEach(session -> {
					String sessionId = (String) ((JSONObject) session).get("sessionId");
					fetchedSessionIds.add(sessionId);
					OpenVidu.activeSessions.computeIfPresent(sessionId, (sId, s) -> {
						String beforeJSON = s.toJson();
						s = s.resetSessionWithJson((JSONObject) session);
						String afterJSON = s.toJson();
						boolean changed = !beforeJSON.equals(afterJSON);
						hasChanged[0] = hasChanged[0] || changed;
						log.info("Available session '{}' info fetched. Any change: {}", sessionId, changed);
						return s;
					});
					OpenVidu.activeSessions.computeIfAbsent(sessionId, sId -> {
						log.info("New session '{}' fetched", sessionId);
						hasChanged[0] = true;
						return new Session((JSONObject) session);
					});
				});

				// Remove closed sessions from activeSessions map
				OpenVidu.activeSessions = OpenVidu.activeSessions.entrySet().stream().filter(entry -> {
					if (fetchedSessionIds.contains(entry.getKey())) {
						return true;
					} else {
						log.info("Removing closed session {}" + entry.getKey());
						hasChanged[0] = true;
						return false;
					}
				}).collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
				log.info("Active sessions info fetched: {}", OpenVidu.activeSessions.keySet());
				return hasChanged[0];
			} else {
				throw new OpenViduHttpException(statusCode);
			}
		} finally {
			EntityUtils.consumeQuietly(response.getEntity());
		}
	}

	private JSONObject httpResponseToJson(HttpResponse response) throws OpenViduJavaClientException {
		JSONParser parser = new JSONParser();
		JSONObject json;
		try {
			json = (JSONObject) parser.parse(EntityUtils.toString(response.getEntity()));
		} catch (org.apache.http.ParseException | ParseException | IOException e) {
			throw new OpenViduJavaClientException(e.getMessage(), e.getCause());
		}
		return json;
	}

}
