/*
 * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
 *
 * 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/.
 */

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Data;
using SafeExamBrowser.Settings.Logging;
using SafeExamBrowser.Settings.Server;
using SafeExamBrowser.SystemComponents.Contracts.PowerSupply;
using SafeExamBrowser.SystemComponents.Contracts.WirelessNetwork;
using Timer = System.Timers.Timer;

namespace SafeExamBrowser.Server
{
	public class ServerProxy : ILogObserver, IServerProxy
	{
		private ApiVersion1 api;
		private AppConfig appConfig;
		private CancellationTokenSource cancellationTokenSource;
		private string connectionToken;
		private int currentPowerSupplyValue;
		private int currentWlanValue;
		private string examId;
		private HttpClient httpClient;
		private ILogger logger;
		private ConcurrentQueue<ILogContent> logContent;
		private string oauth2Token;
		private int pingNumber;
		private IPowerSupply powerSupply;
		private ServerSettings settings;
		private Task task;
		private Timer timer;
		private IWirelessAdapter wirelessAdapter;

		public event TerminationRequestedEventHandler TerminationRequested;

		public ServerProxy(
			AppConfig appConfig,
			ILogger logger,
			IPowerSupply powerSupply = default(IPowerSupply),
			IWirelessAdapter wirelessAdapter = default(IWirelessAdapter))
		{
			this.api = new ApiVersion1();
			this.appConfig = appConfig;
			this.cancellationTokenSource = new CancellationTokenSource();
			this.httpClient = new HttpClient();
			this.logContent = new ConcurrentQueue<ILogContent>();
			this.logger = logger;
			this.powerSupply = powerSupply;
			this.timer = new Timer();
			this.wirelessAdapter = wirelessAdapter;
		}

		public ServerResponse Connect()
		{
			var success = TryExecute(HttpMethod.Get, settings.ApiUrl, out var response);
			var message = ToString(response);

			if (success && TryParseApi(response.Content))
			{
				logger.Info("Successfully loaded server API.");

				var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.ClientName}:{settings.ClientSecret}"));
				var authorization = ("Authorization", $"Basic {secret}");
				var content = "grant_type=client_credentials&scope=read write";
				var contentType = "application/x-www-form-urlencoded";

				success = TryExecute(HttpMethod.Post, api.AccessTokenEndpoint, out response, content, contentType, authorization);
				message = ToString(response);

				if (success && TryParseOauth2Token(response.Content))
				{
					logger.Info("Successfully retrieved OAuth2 token.");
				}
				else
				{
					logger.Error("Failed to retrieve OAuth2 token!");
				}
			}
			else
			{
				logger.Error("Failed to load server API!");
			}

			return new ServerResponse(success, message);
		}

		public ServerResponse Disconnect()
		{
			var authorization = ("Authorization", $"Bearer {oauth2Token}");
			var content = "delete=true";
			var contentType = "application/x-www-form-urlencoded";
			var token = ("SEBConnectionToken", connectionToken);

			var success = TryExecute(HttpMethod.Delete, api.HandshakeEndpoint, out var response, content, contentType, authorization, token);
			var message = ToString(response);

			if (success)
			{
				logger.Info("Successfully terminated connection.");
			}
			else
			{
				logger.Error("Failed to terminate connection!");
			}

			return new ServerResponse(success, message);
		}

		public ServerResponse<IEnumerable<Exam>> GetAvailableExams(string examId = default(string))
		{
			var authorization = ("Authorization", $"Bearer {oauth2Token}");
			var content = $"institutionId={settings.Institution}{(examId == default(string) ? "" : $"&examId={examId}")}";
			var contentType = "application/x-www-form-urlencoded";
			var exams = default(IList<Exam>);

			var success = TryExecute(HttpMethod.Post, api.HandshakeEndpoint, out var response, content, contentType, authorization);
			var message = ToString(response);

			if (success)
			{
				var hasExams = TryParseExams(response.Content, out exams);
				var hasToken = TryParseConnectionToken(response);

				success = hasExams && hasToken;

				if (success)
				{
					logger.Info("Successfully retrieved connection token and available exams.");
				}
				else if (!hasExams)
				{
					logger.Error("Failed to retrieve available exams!");
				}
				else if (!hasToken)
				{
					logger.Error("Failed to retrieve connection token!");
				}
			}
			else
			{
				logger.Error("Failed to load connection token and available exams!");
			}

			return new ServerResponse<IEnumerable<Exam>>(success, exams, message);
		}

		public ServerResponse<Uri> GetConfigurationFor(Exam exam)
		{
			var authorization = ("Authorization", $"Bearer {oauth2Token}");
			var token = ("SEBConnectionToken", connectionToken);
			var uri = default(Uri);

			var success = TryExecute(HttpMethod.Get, $"{api.ConfigurationEndpoint}?examId={exam.Id}", out var response, default(string), default(string), authorization, token);
			var message = ToString(response);

			if (success)
			{
				logger.Info("Successfully retrieved exam configuration.");

				success = TrySaveFile(response.Content, out uri);

				if (success)
				{
					logger.Info($"Successfully saved exam configuration as '{uri}'.");
				}
				else
				{
					logger.Error("Failed to save exam configuration!");
				}
			}
			else
			{
				logger.Error("Failed to retrieve exam configuration!");
			}

			return new ServerResponse<Uri>(success, uri, message);
		}

		public ConnectionInfo GetConnectionInfo()
		{
			return new ConnectionInfo
			{
				Api = JsonConvert.SerializeObject(api),
				ConnectionToken = connectionToken,
				Oauth2Token = oauth2Token
			};
		}

		public void Initialize(ServerSettings settings)
		{
			this.settings = settings;
			httpClient.BaseAddress = new Uri(settings.ServerUrl);

			if (settings.RequestTimeout > 0)
			{
				httpClient.Timeout = TimeSpan.FromMilliseconds(settings.RequestTimeout);
			}
		}

		public void Initialize(string api, string connectionToken, string examId, string oauth2Token, ServerSettings settings)
		{
			this.api = JsonConvert.DeserializeObject<ApiVersion1>(api);
			this.connectionToken = connectionToken;
			this.examId = examId;
			this.oauth2Token = oauth2Token;

			Initialize(settings);
		}

		public void Notify(ILogContent content)
		{
			logContent.Enqueue(content);
		}

		public ServerResponse SendSessionIdentifier(string identifier)
		{
			var authorization = ("Authorization", $"Bearer {oauth2Token}");
			var content = $"examId={examId}&seb_user_session_id={identifier}";
			var contentType = "application/x-www-form-urlencoded";
			var token = ("SEBConnectionToken", connectionToken);

			var success = TryExecute(HttpMethod.Put, api.HandshakeEndpoint, out var response, content, contentType, authorization, token);
			var message = ToString(response);

			if (success)
			{
				logger.Info("Successfully sent session identifier.");
			}
			else
			{
				logger.Error("Failed to send session identifier!");
			}

			return new ServerResponse(success, message);
		}

		public void StartConnectivity()
		{
			foreach (var item in logger.GetLog())
			{
				logContent.Enqueue(item);
			}

			logger.Subscribe(this);
			task = new Task(SendLog, cancellationTokenSource.Token);
			task.Start();
			logger.Info("Started sending log items.");

			timer.AutoReset = false;
			timer.Elapsed += Timer_Elapsed;
			timer.Interval = 1000;
			timer.Start();
			logger.Info("Started sending pings.");

			if (powerSupply != default(IPowerSupply) && wirelessAdapter != default(IWirelessAdapter))
			{
				powerSupply.StatusChanged += PowerSupply_StatusChanged;
				wirelessAdapter.NetworksChanged += WirelessAdapter_NetworksChanged;
				logger.Info("Started monitoring system components.");
			}
		}

		public void StopConnectivity()
		{
			if (powerSupply != default(IPowerSupply) && wirelessAdapter != default(IWirelessAdapter))
			{
				powerSupply.StatusChanged -= PowerSupply_StatusChanged;
				wirelessAdapter.NetworksChanged -= WirelessAdapter_NetworksChanged;
				logger.Info("Stopped monitoring system components.");
			}

			logger.Unsubscribe(this);
			cancellationTokenSource.Cancel();
			task?.Wait();
			logger.Info("Stopped sending log items.");

			timer.Stop();
			timer.Elapsed -= Timer_Elapsed;
			logger.Info("Stopped sending pings.");
		}

		private void SendLog()
		{
			var authorization = ("Authorization", $"Bearer {oauth2Token}");
			var contentType = "application/json;charset=UTF-8";
			var token = ("SEBConnectionToken", connectionToken);

			while (!cancellationTokenSource.IsCancellationRequested)
			{
				try
				{
					if (logContent.TryDequeue(out var c) && c is ILogMessage message)
					{
						var json = new JObject
						{
							["type"] = ToLogType(message.Severity),
							["timestamp"] = ToUnixTimestamp(message.DateTime),
							["text"] = message.Message
						};
						var content = json.ToString();

						TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
					}
				}
				catch (Exception e)
				{
					logger.Error("Failed to send log!", e);
				}
			}
		}

		private void PowerSupply_StatusChanged(IPowerSupplyStatus status)
		{
			try
			{
				var value = Convert.ToInt32(status.BatteryCharge * 100);

				if (value != currentPowerSupplyValue)
				{
					var authorization = ("Authorization", $"Bearer {oauth2Token}");
					var chargeInfo = $"{status.BatteryChargeStatus} at {value}%";
					var contentType = "application/json;charset=UTF-8";
					var gridInfo = $"{(status.IsOnline ? "connected to" : "disconnected from")} the power grid";
					var token = ("SEBConnectionToken", connectionToken);
					var json = new JObject
					{
						["type"] = ToLogType(LogLevel.Info),
						["timestamp"] = ToUnixTimestamp(DateTime.Now),
						["text"] = $"<battery> {chargeInfo}, {status.BatteryTimeRemaining} remaining, {gridInfo}",
						["numericValue"] = value
					};
					var content = json.ToString();

					TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
					currentPowerSupplyValue = value;
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to send power supply status!", e);
			}
		}

		private void Timer_Elapsed(object sender, ElapsedEventArgs args)
		{
			try
			{
				var authorization = ("Authorization", $"Bearer {oauth2Token}");
				var content = $"timestamp={ToUnixTimestamp(DateTime.Now)}&ping-number={++pingNumber}";
				var contentType = "application/x-www-form-urlencoded";
				var token = ("SEBConnectionToken", connectionToken);
				var success = TryExecute(HttpMethod.Post, api.PingEndpoint, out var response, content, contentType, authorization, token);

				if (success)
				{
					if (TryParseInstruction(response.Content, out var instruction))
					{
						switch (instruction)
						{
							case "SEB_QUIT":
								Task.Run(() => TerminationRequested?.Invoke());
								break;
						}
					}
				}
				else
				{
					logger.Error($"Failed to send ping: {ToString(response)}");
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to send ping!", e);
			}

			timer.Start();
		}

		private void WirelessAdapter_NetworksChanged()
		{
			const int NOT_CONNECTED = -1;

			try
			{
				var network = wirelessAdapter.GetNetworks().FirstOrDefault(n => n.Status == WirelessNetworkStatus.Connected);

				if (network?.SignalStrength != currentWlanValue)
				{
					var authorization = ("Authorization", $"Bearer {oauth2Token}");
					var contentType = "application/json;charset=UTF-8";
					var token = ("SEBConnectionToken", connectionToken);
					var json = new JObject { ["type"] = ToLogType(LogLevel.Info), ["timestamp"] = ToUnixTimestamp(DateTime.Now) };

					if (network != default(IWirelessNetwork))
					{
						json["text"] = $"<wlan> {network.Name}: {network.Status}, {network.SignalStrength}%";
						json["numericValue"] = network.SignalStrength;
					}
					else
					{
						json["text"] = "<wlan> not connected";
					}

					TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, json.ToString(), contentType, authorization, token);

					currentWlanValue = network?.SignalStrength ?? NOT_CONNECTED;
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to send wireless status!", e);
			}
		}

		private bool TryParseApi(HttpContent content)
		{
			var success = false;

			try
			{
				var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
				var apis = json["api-versions"];

				foreach (var api in apis.AsJEnumerable())
				{
					if (api["name"].Value<string>().Equals("v1"))
					{
						foreach (var endpoint in api["endpoints"].AsJEnumerable())
						{
							var name = endpoint["name"].Value<string>();
							var location = endpoint["location"].Value<string>();

							switch (name)
							{
								case "access-token-endpoint":
									this.api.AccessTokenEndpoint = location;
									break;
								case "seb-configuration-endpoint":
									this.api.ConfigurationEndpoint = location;
									break;
								case "seb-handshake-endpoint":
									this.api.HandshakeEndpoint = location;
									break;
								case "seb-log-endpoint":
									this.api.LogEndpoint = location;
									break;
								case "seb-ping-endpoint":
									this.api.PingEndpoint = location;
									break;
							}
						}

						success = true;
					}
					 
					if (!success)
					{
						logger.Error("The selected SEB server instance does not support the required API version!");
					}
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to parse server API!", e);
			}

			return success;
		}

		private bool TryParseConnectionToken(HttpResponseMessage response)
		{
			try
			{
				var hasHeader = response.Headers.TryGetValues("SEBConnectionToken", out var values);

				if (hasHeader)
				{
					connectionToken = values.First();
				}
				else
				{
					logger.Error("Failed to retrieve connection token!");
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to parse connection token!", e);
			}

			return connectionToken != default(string);
		}

		private bool TryParseExams(HttpContent content, out IList<Exam> exams)
		{
			exams = new List<Exam>();

			try
			{
				var json = JsonConvert.DeserializeObject(Extract(content)) as JArray;

				foreach (var exam in json.AsJEnumerable())
				{
					exams.Add(new Exam
					{
						Id = exam["examId"].Value<string>(),
						LmsName = exam["lmsType"].Value<string>(),
						Name = exam["name"].Value<string>(),
						Url = exam["url"].Value<string>()
					});
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to parse exams!", e);
			}

			return exams.Any();
		}

		private bool TryParseInstruction(HttpContent content, out string instruction)
		{
			instruction = default(string);

			try
			{
				var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;

				if (json != default(JObject))
				{
					instruction = json["instruction"].Value<string>();
				}
			}
			catch (Exception e)
			{
				logger.Error("Failed to parse instruction!", e);
			}

			return instruction != default(string);
		}

		private bool TryParseOauth2Token(HttpContent content)
		{
			try
			{
				var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;

				oauth2Token = json["access_token"].Value<string>();
			}
			catch (Exception e)
			{
				logger.Error("Failed to parse Oauth2 token!", e);
			}

			return oauth2Token != default(string);
		}

		private bool TryExecute(
			HttpMethod method,
			string url,
			out HttpResponseMessage response,
			string content = default(string),
			string contentType = default(string),
			params (string name, string value)[] headers)
		{
			response = default(HttpResponseMessage);

			for (var attempt = 0; attempt < settings.RequestAttempts && response == default(HttpResponseMessage); attempt++)
			{
				var request = new HttpRequestMessage(method, url);

				if (content != default(string))
				{
					request.Content = new StringContent(content, Encoding.UTF8);

					if (contentType != default(string))
					{
						request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
					}
				}

				foreach (var (name, value) in headers)
				{
					request.Headers.Add(name, value);
				}

				try
				{
					response = httpClient.SendAsync(request).GetAwaiter().GetResult();

					if (request.RequestUri.AbsolutePath != api.LogEndpoint && request.RequestUri.AbsolutePath != api.PingEndpoint)
					{
						logger.Debug($"Completed request: {request.Method} '{request.RequestUri}' -> {ToString(response)}");
					}
				}
				catch (TaskCanceledException)
				{
					logger.Debug($"Request {request.Method} '{request.RequestUri}' did not complete within {settings.RequestTimeout}ms!");
					break;
				}
				catch (Exception e)
				{
					logger.Debug($"Request {request.Method} '{request.RequestUri}' failed due to {e}");
				}
			}

			return response != default(HttpResponseMessage) && response.IsSuccessStatusCode;
		}

		private bool TrySaveFile(HttpContent content, out Uri uri)
		{
			uri = new Uri(Path.Combine(appConfig.TemporaryDirectory, $"ServerExam{appConfig.ConfigurationFileExtension}"));

			try
			{
				var task = Task.Run(async () =>
				{
					return await content.ReadAsStreamAsync();
				});

				using (var data = task.GetAwaiter().GetResult())
				using (var file = new FileStream(uri.LocalPath, FileMode.Create))
				{
					data.Seek(0, SeekOrigin.Begin);
					data.CopyTo(file);
					data.Flush();
					file.Flush();
				}

				return true;
			}
			catch (Exception e)
			{
				logger.Error($"Failed to save file '{uri.LocalPath}'!", e);
			}

			return false;
		}

		private string Extract(HttpContent content)
		{
			var task = Task.Run(async () =>
			{
				return await content.ReadAsStreamAsync();
			});
			var stream = task.GetAwaiter().GetResult();
			var reader = new StreamReader(stream);

			return reader.ReadToEnd();
		}

		private string ToLogType(LogLevel severity)
		{
			switch (severity)
			{
				case LogLevel.Debug:
					return "DEBUG_LOG";
				case LogLevel.Error:
					return "ERROR_LOG";
				case LogLevel.Info:
					return "INFO_LOG";
				case LogLevel.Warning:
					return "WARN_LOG";
			}

			return "UNKNOWN";
		}

		private string ToString(HttpResponseMessage response)
		{
			return $"{(int?) response?.StatusCode} {response?.StatusCode} {response?.ReasonPhrase}";
		}

		private long ToUnixTimestamp(DateTime date)
		{
			return new DateTimeOffset(date).ToUnixTimeMilliseconds();
		}
	}
}
