#!/usr/bin/env python

# This file is part of Window-Switch.
# Copyright (c) 2009-2013 Antoine Martin <antoine@nagafix.co.uk>
# Window-Switch is released under the terms of the GNU GPL v3

import time
import os

from winswitch.util.simple_logger import Logger, msig
from winswitch.util.process_util import subprocess_terminate, SimpleLineProcess
from winswitch.util.config import get_client_pid, save_client_pid, delete_client_pid
from winswitch.util.Xming_util import get_xming_util
from winswitch.util.common import csv_list, visible_command, parse_screensize
from winswitch.objects.global_settings import get_settings
from winswitch.objects.session import Session
from winswitch.objects.server_command import ServerCommand
from winswitch.util.file_io import get_client_session_icon_file, get_client_dir, load_properties, save_properties, get_protocols_config_dir
from winswitch.util.common import save_binary_file, is_valid_file
from winswitch.util.main_loop import callLater
from winswitch.consts import NOTIFY_ERROR, PROTOCOL_NAMES, PROTOCOL_CODENAMES
from winswitch.globals import WIN32


class	ClientUtilBase:
	"""
	Utility superclass for the client-side of the remote display utility classes.
	Contains common code/behaviour.

	The only method that must be implemented is do_real_attach()
	which generally calls exec_client() after generating the command and arguments.
	"""
	def	__init__(self, session_type, update_session_status, notify_callback):
		Logger(self, log_colour=Logger.CYAN)
		self.session_type = session_type
		self.update_session_status = update_session_status		#callback for sending status updates to server via client_base.py
		self.notify = notify_callback
		self.settings = get_settings()
		self.client_log_actions = {}							#map log regular expressions to actions
		self.ignore_process_returncode_after = -1				#set to a positive value to ignore non zero exit code after a while (in seconds) or -1 to never ignore
		self.ignore_process_returncodes = [0, -15, 15, -1, 1]
		self.line_processes = []								#keep reference to all the process here so we can ensure kill them on close() - (detach session may not do it)
		self.xming_util = get_xming_util()						#None on all but win32
		self.shadow_options_required = False					#hint for the UI that options should be shown before starting a shadow
		self.desktop_sizes = get_settings().get_desktop_sizes()	#sizes which can be used for starting/shadowing screens
		self.desktop_bit_depths = []
		self.last_session_event = None							#see SESSION_EVEN_NAMES
		self.options_defaults = None
		self.options_defaults_mtime = 0

	def get_default_options(self):
		"""
			Returns the default options, (re-)loading them if needed.
		"""
		self.load_options_defaults()
		return	self.options_defaults

	def get_options_defaults(self):
		""" this is used initially to create the defaults if none exist
			and is generally overriden in subclasses
		"""
		return	{}

	def load_options_defaults(self):
		"""
			Loads the options defaults from file if it exists and this is the first
			time we load them (or if the file has changed since the last time we loaded them)
			If the file does not exist, it is created with the values from get_options_defaults()
		"""
		filename = os.path.join(get_protocols_config_dir(), "%s.conf" % self.session_type)
		old_filename = os.path.join(get_client_dir(), "%s_options_defaults.conf" % self.session_type)
		if is_valid_file(old_filename):
			try:
				self.slog("moving %s config file from old location %s to %s" % (self.session_type, old_filename, filename))
				os.rename(old_filename, filename)
			except Exception, e:
				self.serr(None, e)
		if is_valid_file(filename):
			try:
				s = os.stat(filename)
				if self.options_defaults is not None and self.options_defaults_mtime>0 and s.st_mtime==self.options_defaults_mtime:
					self.sdebug("nothing to do: the file has not changed")
					return
				self.options_defaults_mtime = s.st_mtime
			except Exception, e:
				self.serr(None, e)
			self.options_defaults = load_properties(filename)
		else:
			self.options_defaults = self.get_options_defaults()
			save_properties(filename, self.options_defaults, header="# %s Options Defaults\n" % self.session_type)
		self.slog("=%s" % self.options_defaults)

	def get_options(self, current_options, default_options):
		""" combines current_options and the default_options into one dict """
		options = {}
		for opts in [current_options, default_options]:
			if opts:
				for k,v in opts.items():
					if k not in options:
						options[k] = v
		self.sdebug("=%s" % options, current_options, default_options)
		return options


	def get_default_desktop_size(self, server_command, command_type, shadowed_session=False):
		ss = parse_screensize(self.settings.default_desktop_size)
		if not ss:
			return	None
		(w,h,_) = ss
		return	(w,h)

	def get_desktop_sizes(self, server_command, command_type, shadowed_session=False):
		self.sdebug(None, server_command, command_type, shadowed_session)
		""" server_command may be set to a ServerCommand, command_type may be set to DESKTOP|SEAMLESS.. """
		if shadowed_session:
			""" when shadowing, we have no choice on the size of what we shadow (GStreamer overrides this) """
			return	None
		if command_type and command_type!=ServerCommand.DESKTOP:
			""" if command_type is specified and is not a DESKTOP, don't offer sizes to choose from (VNC overrides this) """
			return	None
		return	self.desktop_sizes

	def close(self):
		self.sdebug("stopping %s" % csv_list(self.line_processes))
		for proc in self.line_processes:
			if proc.terminated:
				self.slog("%s already terminated" % proc)
			else:
				try:
					proc.stop("Client closing")
				except Exception, e:
					self.serr("error on %s" % proc, e)
		if self.xming_util:
			self.xming_util.close()

	def handle_session_event(self, servers, event, name):
		self.last_session_event = event

	def get_options_widgets(self, server, server_command, shadowed_session=None, current_options=None, default_options=None, change_cb=None):
		"""
		Can be used by subclasses to add widgets to start/edit session screens.
		Must return an array of pairs: [(label_string, widget)]
		@see ClientOptionsBase
		"""
		return	[]

	def get_wm_util(self):
		"""
		Only import this when we need it,
		the server imports this class but does not need any client side
		stuff like wm_util (which tries to load libX11)
		"""
		from winswitch.ui.window_util import get_wm_util
		return	get_wm_util()

	def notify_error(self, title, message):
		"""
		Logs the message as an error, and notifies the user (if the self.notify callback is set).
		"""
		self.serror(None, title, message)
		if self.notify:
			self.notify(title, message, notification_type=NOTIFY_ERROR)

	def get_session_pids(self, session):
		return	self.get_client_pids(session)

	def get_client_pids(self, session):
		pids = []
		for process in session.processes:
			if process:
				if not process.poll() and process.pid > 0:
					pids.append(process.pid)
		if session.client_pid is None:
			pid = get_client_pid(session)
			if not pid:
				pid = -1
			self.sdebug("found pid=%s" % pid, session)
			session.client_pid = pid
		if session.client_pid>0 and session.client_pid not in pids:
			pids.append(session.client_pid)
		return	pids


	def client_resume_session(self, server, session):
		"""
		Simply forwards the request to the server.
		"""
		server.link.connect_to_session(session)

	def client_detach_session(self, server, client, session):
		self.kill_client_processes(session)

	def kill_client_processes(self, session):
		self.do_kill_client_processes(session, session.processes, session.client_pid)

	def do_kill_client_processes(self, session, processes, client_pid):
		""" this method may be called after a delay (see xpra_client_base)
			so we take care to only clear what we actually kill
			(a new client may have started already in the case of "re-connect")
		"""
		session.kill_client = True
		for process in processes:
			subprocess_terminate(process)
			if process in session.processes:
				session.processes.remove(process)
		if session.client_pid==client_pid:
			session.client_pid = None
		session.touch()

	def client_close_session(self, server, client, session):
		pids = self.get_session_pids(session)
		self.slog("pids=%s" % str(pids), server, client, session)
		if len(pids)>0:
			self.get_wm_util().close_windows(pids)

	def client_raise_session(self, server, session):
		pids = self.get_session_pids(session)
		self.slog("pids=%s" % str(pids), server, session)
		if len(pids)>0:
			self.get_wm_util().raise_windows(pids)

	def client_kill_session(self, server, client, session):
		if client:
			client.kill_session(session)

	def	client_start_session(self, server, client, command, screen_size, opts):
		# ask the server to start it:
		if opts is None:
			opts = self.get_default_options()
		client.start_session(command.uuid, self.session_type, screen_size, opts)

	def attach(self, server, session, host, port):
		self.slog(None, server, session, host, port)
		try:
			self.do_real_attach(server, session, host, port)
		except Exception, e:
			self.serr(None, e, server, session, host, port)

	def do_real_attach(self, server, session, host, port):
		raise Exception("this method must be overriden!")


	def handle_line(self, server, session, line):
		self.sdebug(None, server, session, line)
		for re,action in self.client_log_actions.items():
			if re.match(line):
				try:
					action(server, session, line)
				except Exception, e:
					self.serr("error calling %s" % action, e, server, session, line)

	def handle_line_log(self, server, session, line):
		self.slog(server, session, line)

	def handle_line_connected(self, server, session, line):
		""" Call this method when the client log output says we are connected """
		self.slog(server, session, line)
		self.update_session_status(server, session, Session.STATUS_CONNECTED)

	def handle_line_connect_refused(self, server, session, log_line):
		""" Call this method when the client log output says the connection was refused """
		self.client_connect_error(server, session, log_line, "Connection Refused")

	def handle_line_connect_timeout(self, server, session, log_line):
		""" Call this method when the client log output says the connection timed out  """
		self.client_connect_error(server, session, log_line, "Connection Timed Out")

	def client_connect_error(self, server, session, log_line=None, error_type="Connection Error"):
		""" Call this method when detecting a connection failure in the client process output
			This is used by handle_line_connect_refused and handle_line_connect_timeout
			It will only call do_client_connect_error() is the session is in connecting state.
		"""
		#Kill the client before it gets a chance to display its annoying popup error box
		connecting= session.status==Session.STATUS_CONNECTING
		if not connecting:
			self.serror("ignoring connect failed message as session is not in 'connecting' state!", server, session, log_line)
			return
		self.do_client_connect_error(server, session, error_type)

	def do_client_connect_error(self, server, session, error_type="Error"):
		""" Kills the client process and produces an error notification """
		self.kill_client_processes(session)
		protocol_name = PROTOCOL_NAMES[self.session_type]
		if server.platform.startswith("win"):
			#win32 server: means rdp or vnc, must be firewall related or just not enabled
			msg = "Please check that the firewall is not blocking access to it,\n" \
				"and that %s is enabled." % protocol_name
		else:
			if server.ssh_tunnel or session.requires_tunnel:
				#tunnelled sessions: should have had an error from the tunnel tool...
				msg = "More information may be available in the log files"
			else:
				#not tunneled:
				msg = "You may want to ensure that the firewall is not blocking access\n" \
				"alternatively you may be able to use SSH tunnelling with this server."
		msg += "\nThe connection endpoint for %s was %s:%s" % (server.get_display_name(), session.host, session.port)
		if session.tunnels:
			msg += "\n(via an SSH tunnel"
			#for forward in session.tunnels:
			#	(src_port, host, port, _) = forward
			#	if src_port==session.port and (is_localhost(session.host) or session.host=="0.0.0.0"):
			#		msg += " at %s:%s" % (host, port)
			#		break
			msg += ")"
		self.notify_error("%s %s" % (protocol_name, error_type), msg)

	def handle_line_auth_failure(self, server, session, log_line):
		""" This should not happen! Ouch! """
		self.kill_client_processes(session)
		protocol_name = PROTOCOL_NAMES[self.session_type]
		codename = PROTOCOL_CODENAMES[self.session_type]
		self.notify_error("%s Authentication Failed" % protocol_name, "Server %s actively refused our %s connection\n"
						"This should not happen and may be a bug\n" % (server.get_display_name(), codename))

	def handle_line_connection_closed(self, server, session, log_line):
		""" The server has shutdown the connection - just kill the client """
		self.slog(None, server, session, log_line)
		self.kill_client_processes(session)


	#***************************************************************************************************
	# Environment: filter out some irrelevant stuff
	def get_filtered_env(self, session, extra=None):
		"""
		Some things just aren't useful to the app we launch, clutter the log, etc.
		Remove them!
		"""
		DISCARD = ["LS_COLORS"]
		env = {}
		for key,value in os.environ.items():
			if key not in DISCARD:
				env[key] = value
		if extra:
			for k,v in extra.items():
				env[k] = v
		return	env



	def exec_client(self, server, session, args, env=None, onstart_status=None, onexit_status=None, log_args=True, stdindata=None, connect_fail_retry=False):
		"""
		Executes the client program (xpra, vncviewer, rdesktop, nxproxy, ..)
		"""
		if log_args:
			sig = msig(server, session, args, env, onstart_status, onexit_status, log_args)
		else:
			sig = msig(server, session, "[..]", env, onstart_status, onexit_status, log_args)

		def started(line_process):
			self.slog("pid=%s" % line_process.pid, line_process)
			#if we have any data to send to stdin, do it:
			if stdindata:
				try :
					line_process.write(stdindata)
				except Exception, e:
					self.serr("failed to write: %s" % visible_command(stdindata), e, line_process)
			session.processes.append(line_process)
			session.client_pid = line_process.pid
			session.client_start_time = time.time()
			save_client_pid(session)
			def close_session_detach_client():
				self.sdebug()
				self.client_detach_session(server, None, session)
			session.add_status_update_callback(None, Session.STATUS_CLOSED, close_session_detach_client, clear_it=True, timeout=None)
			if onstart_status and session.status!=Session.STATUS_CLOSED:
				self.update_session_status(server, session, onstart_status)
			else:
				if WIN32 and (not server.platform or server.platform.startswith("win") or server.platform.startswith("darwin")):
					#windows can't seem to be able to capture the process stdout, so we won't know when we are connected... sigh
					#Some servers will detect that themselves server-side (Xvnc), but others won't (winvnc4.exe, Apple's ARD..)
					#just assume we are connected shortly after the process is started...
					def assume_we_are_connected():
						self.slog("session=%s" % session)
						if session.status==Session.STATUS_CONNECTING:
							self.update_session_status(server, session, Session.STATUS_CONNECTED)
					callLater(5, assume_we_are_connected)
				else:
					session.touch()

		def ended(line_process):
			self.slog("session=%s, kill_client=%s" % (session, session.kill_client), line_process)
			if line_process in session.processes:
				session.processes.remove(process)
			if line_process in self.line_processes:
				self.line_processes.remove(line_process)
			session.client_pid = None
			delete_client_pid(session)
			if session.status==Session.STATUS_CLOSED:
				return
			end = time.time()
			uptime = end-session.client_start_time
			if not session.kill_client:
				if (process.returncode not in self.ignore_process_returncodes) and \
					(self.ignore_process_returncode_after<0 or uptime<self.ignore_process_returncode_after):
					title = "Session %s failed" % session.name
					message = "..."
					count = 5
					while len(process.previous_lines)>0 and count>0:
						message = "%s\n%s" % (process.previous_lines.pop(), message)
						count -= 1
					self.notify_error(title, message)
				self.client_detach_session(server, None, session)
			session.touch()
			if onexit_status:
				self.update_session_status(server, session, onexit_status)

		def line_handler(line):
			self.handle_line(server, session, line)

		#if the session fails, the server might be first to tell us (and reset the state to available) - we must kill the client:
		def connect_failed():
			if connect_fail_retry:
				self.serror("retrying to connect")
				self.exec_client(server, session, args, env, onstart_status, onexit_status, log_args, stdindata, False)
			else:
				self.do_client_connect_error(server, session, error_type="Failed to connect")

		if env is None:
			env = self.get_filtered_env(session)
		session.add_status_update_callback(Session.STATUS_CONNECTING, Session.STATUS_AVAILABLE, connect_failed, clear_it=True, timeout=30)
		session.kill_client = False
		self.log(sig+" starting SimpleLineProcess")
		process = SimpleLineProcess(args, env, os.getcwd(), line_handler, started, ended, log_full_command=log_args)
		self.line_processes.append(process)
		process.start()

	def schedule_connect_check(self, session, kill_it=False):
		callLater(30, self.check_started, session, kill_it)

	def check_started(self, session, kill_it):
		self.sdebug(None, session, kill_it)
		if session.status==Session.STATUS_CONNECTING:
			#TODO: notify user?
			self.serror("timeout! session still trying to connect", session)
			self.notify_error("Session Timeout", "Session %s failed to connect" % session.name)
			if kill_it:
				session.set_status(Session.STATUS_CLOSED)
				self.kill_client_processes(session)


	def get_window_icon(self, session):
		icon_data = session.get_window_icon_data() or session.get_default_icon_data()
		if not icon_data:
			return None
		icon_filename = get_client_session_icon_file(session.ID)
		save_binary_file(icon_filename, icon_data)
		return	icon_filename




	def win32_Xming_session_start(self, session, ready_cb):
		""" utility for win32 and Xming: start it if needed, record the local display
			ensure the process is recorded (if needed: not when shared)
			and call ready_cb """
		assert WIN32
		def Xming_ready(local_display, display_process):
			self.slog("session=%s" % session, local_display, display_process)
			session.local_display, session.local_display_process = local_display, display_process
			if local_display!=self.xming_util.shared_display:
				self.slog("not a shared display - adding to processes list", local_display, display_process)
				session.processes.append(session.local_display_process)				#only stop the display if it is not shared
			ready_cb()
		def Xming_err(err):
			self.slog(None, err)
			self.notify_error("Failed to start session",
							"Cannot start Xming, please check your configuration. Error message: '%s'\nThe log file may contain more information." % err)
		self.xming_util.get_Xming_display(Xming_ready, Xming_err)
