#!/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.path
import re

from winswitch.util.commands_util import XPRA_COMMAND
from winswitch.globals import USERNAME
from winswitch.consts import XPRA_TYPE, X_PORT_BASE, XPRA_PORT_BASE, LOCALHOST
from winswitch.objects.session import Session
from winswitch.objects.server_session import ServerSession
from winswitch.objects.server_command import ServerCommand
from winswitch.util.common import alphanumspace, save_binary_file, delete_if_exists, xpra_path_workaround, parse_version_string, no_newlines, is_valid_file
from winswitch.util.process_util import exec_nopipe, twisted_exec
from winswitch.util.config import load_session
from winswitch.util.file_io import get_session_password_filename, get_xpra_dir
from winswitch.virt.server_util_base import ServerUtilBase
from winswitch.virt.xpra_common import xpra_cmd
from winswitch.virt.options_common import ENCODING, CLIPBOARD, KEYBOARD_SYNC, JPEG_QUALITY_OPTION, READ_ONLY
from winswitch.net.net_util import wait_for_socket
from winswitch.util.paths import WINSWITCH_LIBEXEC_DIR
from winswitch.util.main_loop import callLater, callFromThread

VERSION_MISMATCH = "Sorry, this (pre-release )?server only works with clients of exactly the same version"
MISMATCH_RE = re.compile(VERSION_MISMATCH)

class	XpraServerUtil(ServerUtilBase):

	def	__init__(self, config, add_session, remove_session, update_session_status, session_failed):
		ServerUtilBase.__init__(self, XPRA_TYPE, XPRA_PORT_BASE, config, add_session, remove_session, update_session_status, session_failed)
		self.prelaunch_enabled = True
		self.connecting_timeout = 20			#20 seconds to go from connecting to connected
		self.auto_upgrade_xpra_sessions = True
		self.auto_upgrade_same_version = False
		self.capture_external_sessions = True
		self.xpra_server_debug_mode = False
		self.start_grace_delay = 15				#ignore "DEAD" xpra sessions for the first few seconds
		xpra_path_workaround()
		from xpra.dotxpra import DotXpra
		self.dotxpra = DotXpra()
		self.watched_directories.append(get_xpra_dir())			#will cause us to detect sessions when something changes in the log directory
		self.ignored_displays = []
		self.ignored_options_for_compare += [READ_ONLY, ENCODING, CLIPBOARD, KEYBOARD_SYNC, JPEG_QUALITY_OPTION]		#these can be set client side

	def get_config_options(self):
		return	self.get_config_options_base(detect=True, log=True, start=True)+[
					"# test the version of all xpra sessions found and upgrade them if needed",
					"auto_upgrade_xpra_sessions",
					"# also force the upgrade when the version is already the latest",
					"auto_upgrade_same_version",
					"# when we find xpra sessions started externally, restart them so they can be used with winswitch",
					"capture_external_sessions",
					"# start the server in debug mode (very verbose!)",
					"xpra_server_debug_mode",
					"# when a session starts, ignore its socket status during the grace period",
					"start_grace_delay"]

	def get_unique_shadow(self, session, user, read_only):
		#there are no "Unique" shadow session with Xpra
		return	None

	def create_shadow_session(self, session, user, read_only, screen_size, options):
		self.slog(None, session, user, read_only, screen_size, options)
		session = ServerUtilBase.create_shadow_session(self, session, user, read_only, screen_size, options)
		session.can_export_sound = False
		session.can_import_sound = False
		session.can_clone_sound = True
		def close_on_suspend():
			self.slog(None, "xpra shadow client has disconnected, stopping the shadow session")
			self.stop_display(session, user, session.display)
		session.add_status_update_callback(Session.STATUS_CONNECTED, Session.STATUS_AVAILABLE, close_on_suspend, clear_it=True, timeout=None)
		return session

	def initialize_new_session(self, uuid, server_command, is_preload, screen_size, display=None, tcp_port=None, options=None):
		session = ServerUtilBase.initialize_new_session(self, uuid, server_command, is_preload, screen_size, display, tcp_port, options)
		session.can_export_sound = False
		session.can_import_sound = False
		session.can_clone_sound = True
		return session

	def xauth_enabled(self, session):
		return	False		#not required

	def can_capture_now(self, session):
		#the session must be connected:
		return session.status in [Session.STATUS_CONNECTED, Session.STATUS_IDLE]

	def take_screenshot(self, ID, display, env, filename, ok_callback, err_callback):
		session = self.config.get_session(ID)
		assert session
		password_file = get_session_password_filename(session.display, session.user)
		save_binary_file(password_file, session.password)
		cmd = [XPRA_COMMAND,
			"--password-file=%s" % password_file,
			"screenshot", filename,
			display]
		from winswitch.ui.capture_util import ExecCaptureDisplay
		c = ExecCaptureDisplay(cmd, display, env, filename, ok_callback, err_callback)
		c.exec_capture(cmd)

	def disconnect(self, session):
		password_file = get_session_password_filename(session.display, session.user)
		save_binary_file(password_file, session.password)
		cmd = [XPRA_COMMAND, "detach", session.display, "--no-mmap", "--password-file=%s" % password_file]
		exec_nopipe(cmd)

	def detect_existing_sessions(self, reloaded_sessions):
		ServerUtilBase.detect_existing_sessions(self, reloaded_sessions)
		self.detect_sessions()

	def get_test_port(self, session):
		#Test xpra server port directly
		return	session.port

	def set_keyboard_mappings(self, session, user):
		""" xpra does its own keymap handling """
		pass


	def	do_prepare_session_for_attach(self, session, user, disconnect, call_when_done):
		self.sdebug("current status=%s" % session.status, session, user, call_when_done)
		if session.status==Session.STATUS_AVAILABLE or disconnect:
			#disconnect: with xpra, we can just kick out the current user by creating the new client connection
			call_when_done()
		else:
			session.add_status_update_callback(None, Session.STATUS_AVAILABLE, call_when_done)


	def get_session_command(self, session, server_command, screen_size, filenames):
		real_command = ServerUtilBase.get_session_command(self, session, server_command, screen_size, filenames)
		if server_command.type==ServerCommand.DESKTOP:
			return self.xnestify(session, real_command, screen_size)
		return real_command

	def prelaunch_start_command(self, session, server_command, user, screen_size, opts, filenames):
		ServerUtilBase.prelaunch_start_command(self, session, server_command, user, screen_size, opts, filenames)
		if server_command.type==ServerCommand.DESKTOP:
			session.command = self.xnestify(session, session.command, screen_size)

	def xnestify(self, session, real_command, screen_size):
		xnest_display = self.port_mapper.get_free_Xnest_display()
		args = ["-name", "'%s'" % alphanumspace(session.name)]
		args += self.get_X_geometry_args(session.screen_size)
		xnest_command = "%s %s :%s" % (self.config.xnest_command, " ".join(args), xnest_display)
		self.slog("replaced desktop with Xnest: %s" % xnest_command, session, real_command, screen_size)
		self.wait_for_xnest(session, xnest_display, real_command)
		return xnest_command

	def wait_for_xnest(self, session, xnest_display, real_command):
		port = X_PORT_BASE + xnest_display
		wait_for_socket(LOCALHOST, port, self.session_start_timeout,
						success_callback=lambda : self.xnest_ready(xnest_display, real_command),
						error_callback=lambda : self.early_failure(session, "Xnest display failed to start"),
						abort_test=lambda : self._is_session_closed(session))

	def xnest_ready(self, xnest_display, real_command):
		kill_parent = os.path.join(WINSWITCH_LIBEXEC_DIR, "kill_parent")
		self.sdebug(None, xnest_display, "%s %s" % (kill_parent, real_command))		#ensure we kill Xnest when command terminates
		proc = exec_nopipe(real_command, extra_env={"DISPLAY":":%s" % xnest_display}, shell=True)
		self.sdebug("real process=%s" % proc, xnest_display, real_command)


	def start_display(self, session, user, is_preload, upgrade=False):
		self.sdebug(None, session, user, is_preload, upgrade)
		password_file = get_session_password_filename(session.display, session.user)
		save_binary_file(password_file, session.password)
		xpra_args_list = ['--bind-tcp=%s:%d' % (session.host, session.port),
							'--password-file=%s' % password_file,
							'--no-daemon']
		if session.name:
			xpra_args_list.append('--session-name=%s' % session.name)
		if self.xpra_server_debug_mode:
			xpra_args_list.append('-d all')

		#JPEG_QUALITY_OPTION has no effect on server
		#if JPEG_QUALITY_OPTION in session.options:
		#	xpra_args_list += ["--jpeg-quality", session.options[JPEG_QUALITY_OPTION]]
		if session.shadowed_display:
			xpra_args_list.append('shadow')
			xpra_args_list.append(session.shadowed_display)
		else:
			if upgrade:
				xpra_args_list.append('upgrade')
			else:
				xpra_args_list.append('start')
			xpra_args_list.append(session.display)
		args_list = xpra_cmd(session.user, XPRA_COMMAND, xpra_args_list)
		env = session.get_env()
		session.xpra_version = self.config.xpra_version
		return self.start_daemon(session, args_list, env)

	def process_log_line(self, session, line):
		def session_error(msg):
			""" this method may be called threaded, so use callFromThread """
			callFromThread(self.session_error, session, msg)

		#skip date header of the form: "2012-08-20 12:28:50,489 "
		SAMPLE="YYYY-MM-DD HH:MM:SS,mmm "
		if line.startswith("20") and len(line)>len(SAMPLE):
			#verify the characters are part of the date:
			is_date_header = True
			for i in xrange(len(SAMPLE)):
				if line[i] not in "0123456789-:, ":
					is_date_header = False
					break
			if is_date_header:
				l = line[len(SAMPLE):]
		else:
			l = line

		if l.startswith("xpra is ready."):
			return	Session.STATUS_AVAILABLE
		elif l.startswith("Handshake complete; enabling connection") \
			and session.status not in [Session.STATUS_CONNECTED, Session.STATUS_CONNECTING, Session.STATUS_IDLE]:
			return	Session.STATUS_CONNECTING
		elif l.startswith("Password matches!"):
			#versions before 0.11:
			return	Session.STATUS_CONNECTED
		elif l.find("client version ")>=0:
			#version >= 0.11
			return	Session.STATUS_CONNECTED
		elif l.startswith("xpra client disconnected."):
			return	Session.STATUS_AVAILABLE
		elif l.startswith("xpra is terminating."):
			return	Session.STATUS_CLOSED
		elif l.startswith("changed session name: "):
			session.name = l[len("changed session name: "):]
			self.save_session(session)
			self.add_session(session)
			return	None
		elif l.startswith("xpra: Fatal IO error"):
			self.serror(None, session, line)
			return	None
		elif l.startswith("RuntimeError: could not open display"):
			if not session.reloading and self.in_session_grace_period(session):
				session_error("Virtual display startup error, Xpra's Xvfb may be misconfigured")
				return	None
		elif l.startswith("X Error of failed request"):
			logfilename = self.get_log_file(session)
			def poll_after_xerror():
				self.poll_server_process(logfilename, session)
			callLater(2, poll_after_xerror)
			return	None
		elif MISMATCH_RE.search(l) is not None:
			m = MISMATCH_RE.search(l)
			msg = l[m.end():]
			self.serror("client version mismatch: %s" % msg, session, l)
			session_error("Client version mismatch: %s" % msg)
		return	None

	def stop_display(self, session, user, display):
		"""
		We simply call "xpra stop :DISPLAY"
		Also schedule a check 5 seconds later to see if the process is still running.
		"""
		#stop via unix domain socket:
		xpra_args_list = ['stop']
		if session.shadowed_display:
			xpra_args_list.append(session.shadowed_display)
		else:
			xpra_args_list.append(display)
		#or stop via tcp socket with:
		#xpra_args_list = ['stop', 'tcp:%s:%d' % (session.host, session.port)]
		password_file = get_session_password_filename(session.display, session.user)
		save_binary_file(password_file, session.password)
		xpra_args_list.append('--password-file=%s' % password_file)
		cmd = xpra_cmd(user, XPRA_COMMAND, xpra_args_list)
		def display_stopped(*args):
			self.sdebug()
		def display_stop_error(*args):
			self.serror(None, *args)
			ServerUtilBase.stop_display(self, session, user, display)
		twisted_exec(cmd, display_stopped, display_stop_error, log_lifecycle=False)

	def detect_sessions(self):
		"""
		Looks for xpra sockets in the user's xpra directory (~/.xpra)
		"""
		try:
			results = self.dotxpra.sockets()
		except Exception, e:
			self.exc(e)
			return
		self.slog("found: %s" % str(results))
		xpra_sessions = self.config.get_sessions_by_type(XPRA_TYPE)
		if not results:
			self.sdebug("no sockets found")
		else:
			for state, display in results:
				try:
					session = self.detect_xpra_session(state, display)
					if session and session in xpra_sessions:
						xpra_sessions.remove(session)
				except Exception, e:
					self.serr("error on %s" % display, e)
		if len(xpra_sessions)>0:
			for session in xpra_sessions:
				if self.in_session_grace_period(session):
					return			#will be re-tested
				self.slog("session no longer found (removing it): %s" % session)
				session.set_status(Session.STATUS_CLOSED)
				if self.update_session_status:
					self.update_session_status(session, session.status)
				self.config.remove_session(session)
		for session in self.config.get_sessions_by_type(XPRA_TYPE):
			self.may_upgrade_session(session)

	def in_session_grace_period(self, session):
		return session.start_time>0 and (time.time()-session.start_time)<self.start_grace_delay

	def detect_xpra_session(self, state, display):
		"""
		Called by detect_xpra_sessions() for each socket it finds.
		Returns the session object for the display (if still valid/active).
		"""
		if state is self.dotxpra.DEAD:
			return	self.detected_dead_session(display)

		session = self.config.get_session_by_display(display)
		if session and session.session_type==XPRA_TYPE:
			return session
		else:
			return self.detected_new_session(state, display)

	def detected_dead_session(self, display):
		"""
		Found a dead socket.
		Remove the session and clear the socket.
		"""
		session = self.config.get_session_by_display(display)
		if session and self.in_session_grace_period(session):
			self.slog("ignoring initial dead session during early startup - will re-probe it shortly", display)
			callLater(self.start_grace_delay, self.schedule_detect_sessions)
			return	session
		#if session: #Once we prelaunch tunnels...
		if session:
			self.config.remove_session(session)
			self.remove_session(session)
			if session.preload:
				self.prelaunch_failures.append(time.time())
				self.may_schedule_prelaunch(username=session.user)
		else:
			self.serror("dead session not found for display: %s" % display, display)

		sockpath = self.dotxpra.server_socket_path(display, False)
		delete_if_exists(sockpath)
		pos = display.rfind("-")		#ie: "hostname-NNN"
		if pos<0:
			pos = 0						#assume ":NNN"
		port_part = display[pos+1:]
		try:
			x_port = X_PORT_BASE + int(port_part)
			self.port_mapper.free_port(x_port)
		except ValueError, e:
			self.serror("could not figure out the port number, %s" % e, display)
			return	None
		return	None

	def detected_new_session(self, state, display):
		"""
		We found a new session we did not know about...
		Try to load settings from disk, otherwise take a guess
		"""
		display_no = int(display[1:])
		self.port_mapper.add_taken_port(X_PORT_BASE+display_no)
		session = load_session(display, True, ServerSession)
		if session:
			if session.ID in self.config.sessions.keys():
				self.sdebug("session already known", state, display)
				return	self.config.sessions.get(session.ID)
			self.slog("found session details on disk: state=%s, preload=%s" % (session.status, session.preload), state, display)
			username = session.user
			if username:
				self.add_local_user(username, None)
		else:
			if display in self.ignored_displays:
				self.sdebug("unmapped display ignored", state, display)
				return	None
			elif not self.capture_external_sessions:
				self.slog("found session - but no details on disk... ignoring it", state, display)
				self.ignored_displays.append(display)
				return	None
			#create a new session object:
			session = self.initialize_new_session(USERNAME, None, False, "", display)
			try:
				from winswitch.ui.icons import getraw
				session.set_default_icon_data(getraw("question"))
			except:
				pass
			session.name = "unknown"
			self.slog("captured xpra session: %s" % session, state, display)
			self.init_upgraded_session(session)
			def with_session_info(*args):
				self.start_upgraded_session(session)
			self.grab_session_info(session, with_session_info)

		self.port_mapper.add_taken_port(session.port)
		self.config.add_session(session)
		self.add_session(session)
		if self.update_session_status:
			self.update_session_status(session, session.status)	#ensures the controller deals with the status
		self.firewall_util.add(session.host, session.port)
		return session

	def init_upgraded_session(self, session):
		self.slog(None, session)
		logfilename = self.get_log_file(session)
		if session.log_watcher_cancel and not session.log_watcher_cancel.is_cancelled():
			session.log_watcher_cancel.cancel()				#stop watching the logfile
		if is_valid_file(logfilename):
			os.rename(logfilename, "%s.oldserver" % logfilename)
		session.status = Session.STATUS_STARTING
		#must change tcp port:
		session.port = self.get_free_port()
		self.add_session(session)		#propagate new port

	def start_upgraded_session(self, session):
		self.start_display(session, None, session.preload, upgrade=True)
		self.watch_existing_log(session)


	def with_session_info(self, session, callback):
		""" Passes 'xpra info' data (or None in case of error)
			to the callback once we have it.
		"""
		def err_info(err):
			self.slog(None, err)
			callback(None)
		def got_info(out):
			self.sdebug(None, out)
			d = {}
			for x in out.splitlines():
				if not x or x.startswith("#"):
					continue
				parts = x.split("=", 1)
				if len(parts)==2:
					d[parts[0]] = parts[1]
			self.sdebug("parsed info=%s" % d, out)
			callback(d)
		cmd = [self.config.xpra_command, "info", session.display]
		twisted_exec(cmd, got_info, err_info, log_lifecycle=False)

	def grab_session_info(self, session, callback):
		""" Populates session attributes from "xpra info",
			then calls the callback.
		"""
		def got_info(d):
			self.sdebug(None, d)
			if d:
				if "encoding" in d:
					session.options[ENCODING] = d.get("encoding")
				session.options[CLIPBOARD] = bool(d.get("clipboard"))
				session.options[KEYBOARD_SYNC] = bool(d.get("keyboard_sync"))
				session.name = d.get("session_name", "")
				vstr = d.get("version") or d.get("server.build.version")
				if vstr:
					session.xpra_version = parse_version_string(vstr)
				if bool(d.get("shadow")):
					session.shadowed_display = session.display
			callback()
		self.with_session_info(session, got_info)

	def grab_session_version(self, session, callback):
		def err_version(err):
			self.serror(None, err)
			callback()
		def got_version(out):
			self.sdebug(None, out)
			session.xpra_version = parse_version_string(no_newlines(out))
			callback()
		cmd = [self.config.xpra_command, "version", session.display]
		twisted_exec(cmd, got_version, err_version, log_lifecycle=False)


	def may_upgrade_session(self, session):
		if not self.auto_upgrade_xpra_sessions:
			return
		if not self.use_gio(session):
			self.slog("upgrading not supported without GIO support")
			return

		current_version = 0
		if getattr(self.config, "xpra_version"):
			current_version = parse_version_string(self.config.xpra_version)

		def with_version(force_upgrade):
			if not hasattr(session, "xpra_version"):
				self.serror("failed to get version information from session %s, ignoring it" % session)
				return
			self.sdebug("xpra_version=%s" % session.xpra_version, force_upgrade)
			if not force_upgrade and session.xpra_version>=current_version:
				self.sdebug("xpra version is up to date")
				return
			self.init_upgraded_session(session)
			self.start_upgraded_session(session)

		if hasattr(session, "xpra_version"):
			#already been through this, take shortcut:
			with_version(False)
			return

		def after_getting_info(*args):
			with_version(self.auto_upgrade_same_version)
		def after_getting_version(*args):
			self.grab_session_info(session, after_getting_info)
		self.grab_session_version(session, after_getting_version)
