#########################################################################
# pglauncher - An unoffical Launcher for the "Project: Gorgon" game     #
# Copyright (C) 2014  Alexander Schlarb                                 #
#                                                                       #
# This program is free software: you can redistribute it and/or modify  #
# it under the terms of the GNU General Public License as published by  #
# the Free Software Foundation, either version 3 of the License, or     #
# (at your option) any later version.                                   #
#                                                                       #
# This program is distributed in the hope that it will be useful,       #
# but WITHOUT ANY WARRANTY; without even the implied warranty of        #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
# GNU General Public License for more details.                          #
#########################################################################

import abc
import json
import logging
import hashlib
import time
import os
import platform
import pwd
import urllib.parse
import zlib

import requests

class PatcherError(Exception):
	pass

class PatcherOutdated(PatcherError):
	def __init__(self, version_latest):
		self.version_latest  = version_latest
		self.version_current = Patcher.PATCHER_LAUNCHER_VERSION
		
		message  = "Offical launcher version is version %d; "                 % (self.version_latest)
		message += "however this launcher is only compatible with version %d" % (self.version_current)
		PatcherError.__init__(self, message)

class ChecksumMismatch(PatcherError):
	def __init__(self, url, expected, generated, type):
		self.url       = url
		self.expected  = expected
		self.generated = generated
		self.type      = type
		
		message = "Checksum mismatch for %s download data from \"%s\": %s != %s" % (type, url, expected, generated)
		PatcherError.__init__(self, message)

class RequestError(PatcherError):
	def __init__(self, url, message):
		self.url = url
		
		message = "Failed to download file \"%s\": %s" % (url, message)
		PatcherError.__init__(self, message)

class RequestStatusError(RequestError):
	def __init__(self, url, status):
		self.url    = url
		self.status = status
		
		message = "Server returned status %d" % (status)
		RequestError.__init__(self, url, message)

class TooManyErrors(PatcherError):
	def __init__(self, chunk_desc):
		self.chunk = chunk_desc
		
		PatcherError.__init__(self, "Too many errors while downloading chunk: %s" % chunk_desc)


class DefaultDirectoryUndetectable(PatcherError, NotImplementedError):
	def __init__(self, reason, detail=None):
		self.reason = reason
		self.detail = detail
		
		message = "Could not detect default game directory"
		if reason == "foreign_platform":
			message = "%s: Cannot detect directories on foreign platforms" % (message)
		
		if detail:
			message = "%s: %s" % (message, detail)
		
		PatcherError.__init__(self, message)
		NotImplementedError.__init__(self, message)



class PatcherCallbacks(metaclass = abc.ABCMeta):
	@abc.abstractmethod
	def handle_patcher_have_launcher_version(self, launcher_version):
		pass
	
	@abc.abstractmethod
	def handle_patcher_have_game_version(self, game_version):
		pass
	
	@abc.abstractmethod
	def handle_patcher_have_welcome_message(self, welcome_message):
		pass
	
	@abc.abstractmethod
	def handle_patcher_progress_total(self, value):
		pass
	
	@abc.abstractmethod
	def handle_patcher_progress_current(self, value_part, value_file, message):
		pass
	
	@abc.abstractmethod
	def handle_patcher_done(self):
		pass


class PatcherCallbacksStub(PatcherCallbacks):
	def handle_patcher_have_launcher_version(self, launcher_version):
		pass
	
	def handle_patcher_have_game_version(self, game_version):
		pass
	
	def handle_patcher_have_welcome_message(self, welcome_message):
		pass
	
	def handle_patcher_progress_total(self, value):
		pass
	
	def handle_patcher_progress_current(self, value_part, value_file, message):
		pass
	
	def handle_patcher_done(self):
		pass


class Patcher:
	PATCHER_LAUNCHER_VERSION = 5
	
	
	@classmethod
	def get_logger(cls, logger=None):
		"""
		Retrieve the logger for this project and add basic stdout logging if no other logging has been set up yet
		"""
		if logger is None:
			logger = logging.getLogger("project-gorgon.launcher.linux.patcher")
		
		# Log everything
		logger.setLevel(logging.DEBUG)
		
		# Add stdout logging if no other mechanism is active
		if not logger.hasHandlers():
			logger.addHandler(logging.StreamHandler())
		
		return logger
	
	
	@classmethod
	def get_default_directory(cls):
		"""
		Returns the default storage directory for launcher data cached by launcher
		
		@return {str}
		"""
		if "PROJECT_GORGON_DATA_HOME" in os.environ:
			return os.environ["PROJECT_GORGON_DATA_HOME"]
		else:
			raise DefaultDirectoryUndetectable("not_implemented")
	
	
	@classmethod
	def get_target_platform(cls):
		"""
		Returns the name of the platform that this launcher will download the binaries for
		
		@return {str}
		"""
		return NotImplemented
	
	
	def __init__(self, callbacks=PatcherCallbacksStub(), logger=None, game_version=None):
		# Get logging instance
		self.logger = self.get_logger(logger)
		
		# Set welcome message cache
		self.welcome_message = None
		
		# Store requested game version (replaced with latest game version if None)
		self.game_version = game_version
		
		# Set progress callback
		self.callbacks = callbacks
		assert isinstance(self.callbacks, PatcherCallbacks)
	
	
	def _fetch_with_progress(self, url, callback_data=None, file_size=-1, total_size=-1, total_offset=0, message=None):
		"""
		Retrieve the given URL - sending updates to the progress callback along the way
		
		@param {str}       url
		       The URL of the file to download
		@param {function} [callback_data({bytes} chunk)]
		       A function to call each time there is a new data chunk, if this function is missing the entire result
		       will be returned
		@param {int}      [file_size]
		       The expected size of the file being downloaded;
		       only supply if that number was been retrieved by a reliable source
		@param {str}      [message]
		       The progress message to show while downloading this file
		"""
		def url_to_filename(url):
			return urllib.parse.urlparse(url).path.split('/')[-1]
		
		def max_1(value):
			return value if value < 1.0 else 1.0
		
		# Create default chunk callback that just accumulates all data
		data = None
		if not callback_data:
			data = b""
			def callback_data(chunk):
				nonlocal data
				data += chunk
		
		# Send initial progress message
		if total_size > -1:
			self.callbacks.handle_patcher_progress_current(0.0, (total_offset / total_size), message)
		else:
			self.callbacks.handle_patcher_progress_current(0.0, 0.0, message)
		
		response = requests.get(url, stream=True)
		
		# Determine file size
		if file_size > -1:
			# Use supplied file size
			pass
		elif 'content-length' in response.headers:
			# Use file size in Content-Length header
			file_size = int(response.headers['content-length'])
		if total_size < 0:
			total_size = file_size
		
		# Handle server errors
		if response.status_code > 0 and (response.status_code < 200 or response.status_code >= 300):
			raise RequestStatusError(url, response.status_code)
		
		retrieved = 0
		for chunk in response.iter_content(10240):
			# Update download progress
			if file_size > -1 and total_size > -1:
				retrieved += len(chunk)
				
				progress_file  = max_1(retrieved / file_size)
				progress_total = max_1((retrieved + total_offset) / total_size)
				self.callbacks.handle_patcher_progress_current(progress_file, progress_total, message)
			
			# Process data chunk
			callback_data(chunk)
		
		# Send final progress message
		self.callbacks.handle_patcher_progress_current(1.0, max_1((file_size + total_offset) / total_size), message)
		
		# Return accumulated data (or None if a real data chunk callback was provided)
		return data
	
	
	def get_welcome_message(self):
		"""
		Retrieve the welcome message with news and greetings from the server of they haven't been downloaded already
		
		@return {str}
		"""
		if not isinstance(self.welcome_message, str):
			self.welcome_message = self.fetch_welcome_message()
		
		return self.welcome_message
	
	
	def fetch_welcome_message(self):
		"""
		Retrieve the welcome message with news and greetings from the server
		
		@return {str}
		"""
		self.logger.info("Getting welcome message")
		result = self._fetch_with_progress(self.URL_WELCOME_MESSAGE, message="Welcome message")
		result = result.decode("iso-8859-1", "replace")
		result = result.replace("<b>", "**").replace("</b>", "**")
		result = result.replace("<i>", "/").replace("</i>", "/")
		self.callbacks.handle_patcher_have_welcome_message(result)
		return result
	
	
	def get_game_version(self):
		"""
		Returns the game version used by this patcher
		
		If the `game_version` parameter was `None` during intialization, then this function will fetch the current
		game version and store it as used game version before returning.
		
		@return {int}
		"""
		if not isinstance(self.game_version, int):
			self.game_version = self.fetch_game_version()
		
		return self.game_version
	
	
	def fetch_launcher_version(self):
		"""
		Retrieve the version number of the latest launcher
		
		@return {int}
		"""
		self.logger.info("Getting launcher version")
		result = self._fetch_with_progress(self.URL_LAUNCHER_VERSION, message="Launcher version")
		result = int(result.decode("utf-8"))
		self.callbacks.handle_patcher_have_launcher_version(result)
		return result
	
	
	def fetch_game_version(self):
		"""
		Retrieve the current game version
		
		@return {int}
		"""
		self.logger.info("Getting game version")
		result = self._fetch_with_progress(self.URL_GAME_VERSION, message="Game version")
		result = int(result.decode("utf-8"))
		self.callbacks.handle_patcher_have_game_version(result)
		return result
	
	
	def fetch_game_manifest(self):
		"""
		Retrieve information about the files of the given game version
		
		@return {dict}
		"""
		game_version = self.get_game_version()
		
		self.logger.info("Retrieving game manifest for client version %d" % (game_version))
		result  = self._fetch_with_progress(self.URL_GAME_MANIFEST % (game_version), message="Game manifest")
		return json.loads(result.decode("utf-8"))
	
	
	def patch(self, directory=None, force_file_checksums=False, check_launcher_version=True):
		"""
		Patch the game in the given directory
		
		@param {str}  [directory]
		       The path on the directory where the files should be downloaded/patched to
		       (uses {@see get_default_directory()} if value is unset})
		@param {bool} [force_file_checksums]
		       Should the files always be checksumed to verify the contents?
		       (Without this a file is considered valid if only its metadata is OK)
		@param {bool} [check_launcher_version]
		       Should the version number of the official launcher be checked to see if this launcher
		       might be incompatible?
		"""
		try:
			# Use default directory if none was provided
			if not directory:
				directory = self.get_default_directory()
		
			# Send initial progress message
			self.callbacks.handle_patcher_progress_total(0.0)
		
			# Download welcome message (used by all launchers through callback)
			self.get_welcome_message()
		
			# Check if the official launcher has gotten an update
			if check_launcher_version:
				version_latest = self.fetch_launcher_version()
				if version_latest != self.PATCHER_LAUNCHER_VERSION:
					# Too risky to continue
					raise PatcherOutdated(version_latest)
		
			# Retrieve manifest
			manifest = self.fetch_game_manifest()
		
			# Make sure target directory exists
			try:
				os.makedirs(directory)
			except FileExistsError:
				pass
		
			# Process all entries
			num_total = len(manifest["Entries"])
			for num_current, entry in enumerate(manifest["Entries"]):
				entry_target = os.path.join(directory, entry["AssetName"])
				self.patch_file(entry_target, entry, force_file_checksums)
			
				# Send update on total number of entries patched
				self.callbacks.handle_patcher_progress_total((num_current / num_total))
		
			# Send final patching update
			self.callbacks.handle_patcher_progress_total(1.0)
		
			self.callbacks.handle_patcher_done()
		except PatcherError as error:
			self.logger.error(str(error))
			raise
	
				
	def patch_file(self, file_path, entry, force_file_checksums=False):
		"""
		Patch the given file within the given game directory
		
		@param {str}  file_path
		       The path of the file to patch
		@param {dict} entry
		       A patch file entry as stored in the `Manifest.json` file (see {@see fetch_game_manifest})
		@param {bool} [force_file_checksums]
		       Should the files always be checksumed to verify the contents?
		       (Without this a file is considered valid if only its metadata is OK)
		"""
		asset_name  = entry["AssetName"]
		entry_mtime = time.mktime(time.strptime(entry["Timestamp"][:14], "%Y%m%d%H%M%S"))
		
		# Check if file is still intact
		if force_file_checksums:
			file_ok = self.verify_file(file_path, asset_name, entry["Checksum"])
		else:
			file_ok = self.verify_file(file_path, asset_name, entry["Checksum"], entry_mtime, entry["UncompressedSize"])
		
		if not file_ok:
			# Download file
			self.download_game_file(file_path, asset_name, entry["Chunks"])
		
		# Set correct file timestamp
		os.utime(file_path, (entry_mtime, entry_mtime))
	
	
	def verify_file(self, file_path, asset_name, entry_checksum, entry_mtime=None, entry_size=None):
		if os.path.exists(file_path):
			# Consider file OK if static file properties are intact
			if entry_mtime and entry_size:
				stat = os.stat(file_path)
				if stat.st_size == entry_size and stat.st_mtime == int(entry_mtime):
					# File probably OK :-)
					self.logger.debug("File unchanged: %s (contents not verified)" % (asset_name))
					return True
			
			# Create MD5 hash from file
			file_hash = hashlib.new("md5")
			with open(file_path, "rb") as file:
				chunk = file.read(1014)
				while len(chunk) > 0:
					file_hash.update(chunk)
					chunk = file.read(1014)
			
			# Compare hash
			if file_hash.hexdigest() == entry_checksum:
				# File is still OK :-)
				self.logger.debug("File unchanged: %s" % (asset_name))
				return True
		
		return False
	
	
	def download_game_file(self, file_path, asset_name, chunks):
		"""
		Download all chunks of the given game file, uncompress them and merge them together
		
		@param {str} file_path
		       Where to store the file on the local disk
		@param {str} asset_name
		       The name of the file to fetch
		@param {int} chunks
		       Chunk information as stored in the `Manifest.json` for each file entry (see {@see fetch_game_manifest})
		"""
		# Make sure target directory exists
		try:
			os.makedirs(os.path.dirname(file_path))
		except FileExistsError:
			pass
		
		# Create file if it does not exist
		open(file_path, "ab").close()
		
		# Calculate total compressed chunk size
		compressed_total = 0
		for chunk in chunks:
			compressed_total += chunk["CompressedSize"]
		
		compressed_offset = 0
		with open(file_path, "r+b") as file:
			for chunk_idx, chunk in enumerate(chunks):
				# Generate short human-readable description for chunk
				chunk_desc = "{0} ({1}/{2})".format(asset_name, (chunk_idx+1), len(chunks))
				
				# Seek to given file offset
				file.seek(chunk["Offset"])
				
				chunk_seek_end = file.tell() + chunk["UncompressedSize"]
				if os.stat(file_path).st_size >= chunk_seek_end:
					# Check if chunk is still OK (for downloading only missing chunks)
					
					chunk_hash = hashlib.new("md5")
					while file.tell() < chunk_seek_end:
						chunk_bufsize = 1024 if (file.tell()+1024 <= chunk_seek_end) else (chunk_seek_end - file.tell())
						chunk_hash.update(file.read(chunk_bufsize))
					
					if chunk_hash.hexdigest() == chunk["UncompressedChecksum"]:
						# Skip this chunk :-)
						self.logger.debug("File chunk unchanged: %s", chunk_desc)
						compressed_offset += chunk["CompressedSize"]
						continue
					
					# Seek to file start again
					file.seek(chunk["Offset"])
				
				# Retry up to 3 times to download a single file
				failure_count = 0
				while failure_count < 3:
					try:
						self.download_game_file_chunk(file, chunk,
								asset_name, chunk_idx, len(chunks),
								compressed_total, compressed_offset
						)
					except ChecksumMismatch as error:
						self.logger.error("File chunk \"%s\" corrupt: %s", chunk_desc, str(error))
						failure_count += 1
					except RequestError as error:
						self.logger.error("File chunk \"%s\" download failed: %s", chunk_desc, str(error))
						failure_count += 1
					else:
						break
				if failure_count > 3:
					raise TooManyErrors(chunk_desc)
				
				compressed_offset += chunk["CompressedSize"]
				
			# Truncate remaining bytes (if the new file is shorter than the old one)
			file.truncate()
	
	
	def download_game_file_chunk(self, file, chunk, asset_name, chunk_idx, chunk_cnt,
	                             compressed_total=-1, compressed_offset=0):
		# Create stream data decompressor
		#NOTE: Without the `winsize` parameter set to `zlib.MAX_WBITS` decompression will fail with an error:
		#      zlib.error: Error -3 while decompressing data: incorrect header check
		#NOTE: Without bitwise adding 32 to the `winsize` parameter decompression will fail with an error:
		#      zlib.error: Error -3 while decompressing data: invalid distance too far back
		# More information about options: http://stackoverflow.com/questions/1838699
		decompressor = zlib.decompressobj(zlib.MAX_WBITS|32)
		
		# Define hash object for verification of compressed and uncompressed chunks
		chunk_hash_compressed   = hashlib.new("md5")
		chunk_hash_uncompressed = hashlib.new("md5")
		
		# Define data writing function
		def callback_data(data):
			# Decompress data
			decompressed = decompressor.decompress(data)
			
			# Write the decompressed data to file
			file.write(decompressed)
			
			# Update checksums
			chunk_hash_compressed.update(data)
			chunk_hash_uncompressed.update(decompressed)
		
		# Do it
		self.logger.info("Downloading file: %s (%d/%d)" % (asset_name, (chunk_idx + 1), chunk_cnt))
		url = self.URL_GAME_FILE % (self.get_game_version(), asset_name, chunk_idx)
		if asset_name.endswith(".dll") \
		or asset_name.endswith(".resS") \
		or asset_name.endswith(".exe"):
			# Somehow files with these extensions require an '.dat' ending appended to them…
			url += '.dat'
		
		self._fetch_with_progress(url, callback_data, chunk["CompressedSize"],
		                          compressed_total, compressed_offset, asset_name)
		
		# Verify checksums
		checksum = chunk_hash_compressed.hexdigest()
		if checksum != chunk["CompressedChecksum"]:
			raise ChecksumMismatch(url, checksum, chunk["CompressedChecksum"],   "compressed")
		
		checksum = chunk_hash_uncompressed.hexdigest()
		if checksum != chunk["UncompressedChecksum"]:
			raise ChecksumMismatch(url, checksum, chunk["UncompressedChecksum"], "uncompressed")


class LinuxPatcher(Patcher):
	URL_WELCOME_MESSAGE  = "http://client.projectgorgon.com/welcome.txt"
	URL_LAUNCHER_VERSION = "http://client.projectgorgon.com/launcherversion_win.txt"
	URL_GAME_VERSION     = "http://client.projectgorgon.com/fileversion.txt"
	URL_GAME_MANIFEST    = "http://cdn.projectgorgon.com/v%d/launcherpayloads/LinuxPlayer/Manifest.json"
	URL_GAME_FILE        = "http://cdn.projectgorgon.com/v%d/launcherpayloads/LinuxPlayer/%s.%04d"
	
	
	@classmethod
	def get_default_directory(cls):
		"""
		Returns the default storage directory for launcher data cached by launcher
		
		The directory path generated by this function is based on the XDG base directory specification, however it does
		not quite obey it!
		
		In short, this path will be returned:
		`${PROJECT_GORGON_DATA_HOME:-${XDG_DATA_HOME:-${HOME:-~}/.local/share}/../games/project-gorgon}`
		
		@return {str}
		"""
		try:
			return Patcher.get_default_directory()
		except DefaultDirectoryUndetectable: pass
		
		if platform.system() == cls.get_target_platform():
			directory_home = os.environ.get("HOME", os.path.expanduser("~"))
			directory_data = os.environ.get("XDG_DATA_HOME", os.path.join(directory_home, '.local', 'share'))
			return os.path.join(directory_data, '..', 'games', 'project-gorgon', 'LinuxPlayer')
		else:
			raise DefaultDirectoryUndetectable("foreign_platform")
	
	@classmethod
	def get_target_platform(cls):
		return "Linux"
	
	@classmethod
	def get_binary_name(cls):
		return "ProjectGorgon"



class WindowsPatcher(Patcher):
	URL_WELCOME_MESSAGE  = "http://client.projectgorgon.com/welcome.txt"
	URL_LAUNCHER_VERSION = "http://client.projectgorgon.com/launcherversion_win.txt"
	URL_GAME_VERSION     = "http://client.projectgorgon.com/fileversion.txt"
	URL_GAME_MANIFEST    = "http://cdn.projectgorgon.com/v%d/launcherpayloads/WindowsPlayer/Manifest.json"
	URL_GAME_FILE        = "http://cdn.projectgorgon.com/v%d/launcherpayloads/WindowsPlayer/%s.%04d"
	
	
	@classmethod
	def get_default_directory(cls):
		"""
		Returns the default storage directory for launcher data cached by launcher
		
		@return {str}
		"""
		try:
			return Patcher.get_default_directory()
		except DefaultDirectoryUndetectable: pass
		
		if platform.system() == cls.get_target_platform():
			directory_userprofile  = os.environ.get("USERPROFILE", os.path.expanduser("~"))
			directory_localappdata = os.environ.get("LOCALAPPDATA", os.path.join(directory_userprofile, "Local Settings", "Application Data"))
			return os.path.join(directory_localappdata, "ProjectGorgon", "WindowsPlayer")
		elif platform.system() == "Linux":
			# Use standard Windows path within default WINEPREFIX
			username = os.environ.get("USER", pwd.getpwuid(os.getuid()).pw_name)
			directory_wineprefix   = os.environ.get("WINEPREFIX", os.path.expanduser("~/.wine"))
			directory_userprofile  = os.path.join(directory_wineprefix, "dosdevices", "c:", "users", username)
			directory_localappdata = os.path.join(directory_userprofile, "Local Settings", "Application Data")
			return os.path.join(directory_localappdata, "ProjectGorgon", "WindowsPlayer")
		else:
			raise DefaultDirectoryUndetectable("foreign_platform")
	
	@classmethod
	def get_target_platform(cls):
		return "Windows"
	
	@classmethod
	def get_binary_name(cls):
		return "WindowsPlayer.exe"
