"""Remote control of Grail.

NOTE NOTE NOTE: See <http://monty.cnri.reston.va.us/grail-0.3/help/rc.html>
for details on this interface.

On Unix systems with the DISPLAY environment variable set, the socket
file name will typically be /tmp/.grail-unix/$USER-$DISPLAY, and the
directory will be protected with mode 0700.  The actual file name used
is available from the rendezvous_name() function.  You can also define
what file to use by setting the environment variable GRAIL_REMOTE.
TBD: this should also be made a preference.

This module essentially opens the socket and registers it with Tk so
when data is readable on it, registered callbacks are executed.  This
is a really simple minded string based protocol, with a synchronous
server model.  Commands are limited to 1024 bytes.

TBD: Port this to non-Unix systems, use CCI and ILU.

Exported functions:

start()
	starts the remote controller

stop()
	stops the remote controller

register(cmdstr, callback)
	registers a callback function for handling cmdstr commands.
	The callback has the following form:

	callback(cmdstr, cmdargs)

	where cmdargs is the remainder of the string read from the
	socket.  Note that you can register more than one callback for
	a particular command string; they are invoked in the order
	they were registered.

unregister(cmdstr, callback=None)
	unregisters the callback for the given command string.  If
	callback is not specified, all callbacks for the cmdstr are
	unregistered.

register_loads()
	registers a callback for the command string 'LOAD' which loads
	a given URL into the latest browser window.  The URL to load
	is the cmdargs for the command.  This also registers the
	'LOADNEW' command string which pops up a new browser and loads
	the URL into that browser window.

unregister_loads()
	unregisters the built-in LOAD and LOADNEW callbacks.

rendezvous_name()
	returns the absolute path to the socket file.


Exported exceptions:

InitError       - the socket couldn't be initialized
ClashError      - another Grail is already being remote controlled
BadCommandError - a badly formatted command was received
NoHandlerError  - no handler for the received command has been registered


Example (put this in your grailrc.py file):

# Turn on remote control.  Ignore error that get's raised if some
# other Grail is being remote controlled.
import RemoteControl
RemoteControl.register_loads()
try:
    RemoteControl.start()
except RemoteControl.ClashError:
    pass

"""


# errors
InitError = 'RemoteControl.InitError'
ClashError = 'RemoteControl.ClashError'
BadCommandError = 'RemoteControl.BadCommandError'
NoHandlerError = 'RemoteControl.NoHandlerError'

_controller = None
_filename = None
_loads_registered = None

def _create():
    global _controller
    if not _controller:
	_controller = Controller()

def start():
    _create()
    _controller.start()

def stop():
    if _controller:
	_controller.stop()

def register(cmdstr, callback):
    _create()
    _controller.register(cmdstr, callback)

def unregister(cmdstr, callback):
    _create()
    _controller.unregister(cmdstr, callback)

def register_loads():
    _create()
    global _loads_registered
    if not _loads_registered:
	_controller.register('LOAD', _controller.load_cmd)
	_controller.register('LOADNEW', _controller.load_new_cmd)
	_loads_registered = 1

def unregister_loads():
    global _loads_registered
    if _loads_registered:
	_controller.unregister('LOAD', _controller.load_cmd)
	_controller.unregister('LOADNEW', _controller.load_new_cmd)
	_loads_registered = None



import tempfile
import os
import socket
import regex
import string
from Tkinter import tkinter
from grailutil import *

# The file structure.  Modeled after X11
_filename = getenv('GRAIL_REMOTE')
if not _filename:
    TMPDIR = tempfile.gettempdir()
    USER = getenv('USER') or getenv('LOGNAME')
    XDISPLAY = getenv('DISPLAY') or ':0'
    _filename = os.path.join(TMPDIR,
			     os.path.join('.grail-unix',
					  '%s-%s' % (USER, XDISPLAY)))


class Controller:
    def __init__(self, path=_filename):
	# register a destruction handler with the Grail Application object
	from __main__ import app
	self._app = app
	app.register_on_exit(self._close)
	# calculate the socket's filename
	self._path = path
	self._fileno = None
	self._socket = None
	self._enabled = None
	# Don't create the socket now, because we want to allow
	# clients of this class to register callbacks for commands
	# first.
	self._cbdict = {}
	self._cmdre = regex.compile('\([^ \t]+\)\(.*\)')

    def start(self):
	"""Begin listening for remote control commands."""
	# initialize the socket
	if self._fileno is None:
	    # for security, create the file structure
	    head, self._filename = os.path.split(self._path)
	    dirhier = []
	    while head and not os.path.isdir(head):
		head, t = os.path.split(head)
		dirhier.insert(0, t)
	    for dir in dirhier:
		head = os.path.join(head, dir)
		os.mkdir(head, 0700)
	    self._filename = self._path
	    # TBD: What do we do with multiple Grail processes?  Which
	    # one do we remote control?
	    if os.path.exists(self._filename):
		# first make sure that the socket is connected to a
		# live Grail process.  E.g. if Python core dumped, the
		# exit handler won't run so you'd be dead in the
		# water.
		s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
		try:
		    s.connect(self._filename)
		    s.send('PING NOACK')
		    s.close()
		    raise ClashError
		except socket.error, (errno, msg):
		    os.unlink(self._filename)
		    s.close()
	    # create the FIFO object
	    s = self._socket = socket.socket(socket.AF_UNIX,
					     socket.SOCK_STREAM)
	    s.bind(self._filename)
	    s.listen(1)
	    # register with Tk
	    self._fileno = s.fileno()
	    if self._fileno < 0:
		self._fileno = None
		raise InitError
	if not self._enabled:
	    self._enabled = 1
	    tkinter.createfilehandler(
		self._fileno, tkinter.READABLE, self._dispatch)
	    self.register('PING', self.ping_cmd)

    def stop(self):
	"""Stop listening for remote control commands."""
	if self._enabled:
	    self._enabled = None
	    tkinter.deletefilehandler(self._fileno)

    def register(self, cmdstr, callback):
	"""Register command string, callback function pairs.

	Command format is 'CMDSTR ARGS' where CMDSTR is some command
	string as defined by the client of this class.  ARGS is
	anything that follows, and is command specific.

	Format of the callback method is: callback(cmdstr, argstr).
	More than one callback can be defined for a command, and they
	are called in the order they are registered in.  Note that any
	exceptions raised in the callback are passed straight up
	through to Grail.
	"""
	if self._cbdict.has_key(cmdstr):
	    cblist = self._cbdict[cmdstr]
	    cblist.append(callback)
	else:
	    self._cbdict[cmdstr] = [callback]

    def unregister(self, cmdstr, callback=None):
	"""Unregister a command string, callback mapping.

	If callback is None (the default), this unregisters all
	callbacks associated with a command.
	"""
	if self._cbdict.has_key(cmdstr):
	    cblist = self._cbdict[cmdstr]
	    if callback and callback in cblist:
		cblist.remove(callback)
	    else:
		del self._cbdict[cmdstr]

    # private methods

    def _close(self):
	self.stop()
## 	if self._filename:
## 	    os.unlink(self._filename)

    def _dispatch(self, *args):
	conn, addr = self._socket.accept()
	rawdata = conn.recv(1024)
	# strip off the command string
	string.strip(rawdata)
	if self._cmdre.match(rawdata) < 0:
	    print 'Remote Control: Ignoring badly formatted command:', rawdata
	# extract the command and args strings
	command = string.strip(self._cmdre.group(1))
	argstr = string.strip(self._cmdre.group(2))
	# look up the command string
	if not self._cbdict.has_key(command):
	    print 'Remote Control: Ignoring unrecognized command:', command
	cblist = self._cbdict[command]
	# call all callbacks in list
	for cb in cblist:
	    cb(command, argstr, conn)

    # convenience methods

    def _do_load(self, uri, in_new_window=None):
	def _new_browser(b):
	    from Browser import Browser
	    return Browser(b.master)

	browsers = self._app.browsers[:]
	if not len(browsers):
	    return

	b = None
	if not in_new_window:
	    browsers.reverse()
	    # find the last browser who's context is not showing
	    # source
	    for b in browsers:
		if not b.context.show_source:
		    break
	    else:
		b = None
	if not b:
	    b = _new_browsers(browsers[-1])
	b.context.load(uri)

    def load_cmd(self, cmdstr, argstr, conn):
	self._do_load(argstr)

    def load_new_cmd(self, cmdstr, argstr, conn):
	self._do_load(argstr, in_new_window=1)

    def ping_cmd(self, cmdstr, argstr, conn):
	try:
	    if argstr <> 'NOACK':
		conn.send('ACK')
	except socket.error:
	    print 'RemoteControl: unable to acknowledge PING'
