#!/usr/bin/env python
# ccc - a console chat client for asss
# <grelminar@yahoo.com>

# dist: public

import sys, time, curses, socket, select, os

ship_names = \
{
	0: 'Warbird',
	1: 'Javelin',
	2: 'Spider',
	3: 'Leviathan',
	4: 'Terrier',
	5: 'Weasel',
	6: 'Lancaster',
	7: 'Shark',
	8: 'Spectator'
}

class MyWindows:
	def __init__(me):
		me.stdscr = me.twin = me.iwin = me.pwin = None


class Bindings:
	def __init__(me):
		me.bindings = {}

		me.yes = 1
		me.no = 0
		me.true = 1
		me.false = 0

	def bind(me, seq, func, *args):
		if type(seq) == type(''):
			for c in range(1, 32):
				seq = seq.replace('^%c' % (c + ord('A') - 1), '%c' % c)
			seq = seq.replace('\\e', chr(27))
			seq = seq.replace('\\E', chr(27))
			seq = map(ord, seq)
		if type(seq) == type(0):
			seq = [seq]
		if type(seq) == type([]):
			seq = tuple(seq)

		def realfunc(args = args):
			func(*args)

		me.bindings[seq] = realfunc

	def getbinding(me, seq):
		if type(seq) == type(''):
			seq = map(ord, seq)
		seq = tuple(seq)
		return me.bindings.get(seq)


	# the possible functions
	def finish_line(me):
		global edit
		l = edit.gettext()
		edit.cleartext()
		if l:
			process_typed(l)

	def kill_line(me):
		global edit
		edit.cleartext()

	def backspace(me):
		global edit
		edit.backspace()

	def delete(me):
		global edit
		edit.delete()

	def curs_left(me):
		global edit
		edit.curs_left()

	def curs_right(me):
		global edit
		edit.curs_right()

	def curs_up(me):
		global edit
		edit.curs_up()

	def curs_down(me):
		global edit
		edit.curs_down()

	def curs_home(me):
		global edit
		edit.curs_home()

	def curs_end(me):
		global edit
		edit.curs_end()

	def scroll_up(me, lines = 1):
		global scrollback
		scrollback += lines

	def scroll_down(me, lines = 1):
		global scrollback
		scrollback -= lines
		if scrollback < 0:
			scrollback = 0



class Line:
	def __init__(me, msg, color, tm = 0):
		me.msg = msg
		me.color = color
		if tm == 0:
			me.time = time.time()
		else:
			me.time = tm

		me.lastopts = None
		me.lastres = None

	def wordbreak(me, width, showtime = 0, indent = 2):

		if me.lastopts is not (width, showtime, indent):

			def totlen(wds):
				t = len(wds)
				for w in wds:
					t += len(w)
				return t

			words = me.msg.split(' ')
			lines = []
			if showtime:
				cl= [time.strftime('[%I:%M]', time.localtime(me.time))]
			else:
				cl = []
			
			for w in words:
				# too big to fit on a line alone
				while len(w) > width:
					# grab what can fit on this line
					if cl:
						i = width - totlen(cl) - 1
					else:
						i = width
					# add to this line, start new
					cl.append(w[:i])
					lines.append(cl)
					cl = [''] * indent
					w = w[i:]
				# if it will overflow this line
				if len(w) + totlen(cl) + 1 > width:
					lines.append(cl)
					cl = [''] * indent
				# now add it
				cl.append(w)

			# append last partial line
			lines.append(cl)

			me.lastres = map(lambda wds: ' '.join(wds), lines)

		return me.lastres


class Text:
	def __init__(me):
		me.lines = []

	def addline(me, l):
		global keeplines
		me.lines.append(l)
		if len(me.lines) > keeplines:
			me.lines = me.lines[-keeplines:]

	def display(me, scr):
		global scrollback, showtime, indent, newcrap

		# get window size
		h, w = scr.getmaxyx()
		
		rlines = []
		# only work on the last h lines
		for l in me.lines[-(h+scrollback):]:
			rlines.extend(map(lambda ln: (ln, l.color),
				l.wordbreak(w, showtime, indent)))
		rlines.reverse()
		# go that many lines up
		if scrollback >= len(rlines):
			scrollback = len(rlines) - 1
		rlines = rlines[scrollback:]
		# and cut to the top of the window
		rlines = rlines[:h]

		# draw them
		y = h - 1
		scr.erase()
		for l, c in rlines:
			scr.addstr(y, 0, l, c)
			y = y - 1

		if scrollback < newcrap:
			newcrap = scrollback
		if newcrap:
			s = '%d more' % newcrap
			scr.addstr(h-1, w-len(s)-1, s, curses.A_REVERSE)

		scr.noutrefresh()


class Edit:
	def __init__(me):
		me.line = []
		me.i = 0
		me.lastw = 0
		me.lastmax = 0

	def display(me, scr):
		h, w = scr.getmaxyx()
		me.lastw = w
		me.lastmax = w * h - 1
		if len(me.line) > me.lastmax:
			me.line = me.line[:me.lastmax]
		if me.i > me.lastmax:
			me.i = me.lastmax
		scr.erase()
		for c in me.line:
			scr.addch(c)
		scr.move(me.i / w, me.i % w)
		scr.noutrefresh()

	def gettext(me):
		return ''.join(map(chr, me.line))

	def cleartext(me):
		me.__init__()

	def backspace(me):
		if me.i > 0:
			me.line = me.line[:me.i-1] + me.line[me.i:]
			me.i -= 1

	def delete(me):
		if me.i < len(me.line):
			me.line = me.line[:me.i] + me.line[me.i+1:]

	def curs_left(me):
		if me.i > 0:
			me.i -= 1

	def curs_right(me):
		if me.i < len(me.line):
			me.i += 1

	def curs_up(me):
		if me.i > me.lastw:
			me.i -= me.lastw

	def curs_down(me):
		if me.i + me.lastw <= len(me.line):
			me.i += me.lastw

	def curs_home(me):
		me.i = 0
	def curs_end(me):
		me.i = len(me.line)

	def insert(me, ch):
		if len(me.line) < me.lastmax:
			me.line.insert(me.i, ch)
			me.i += 1


class Player:
	def __init__(me, name, freq):
		me.name = name
		me.freq = freq


class Colors:
	def __init__(me):
		from curses import init_pair, color_pair, A_BLINK, COLORS, COLOR_PAIRS

		me.colors = COLORS
		me.pairs = COLOR_PAIRS

		if me.pairs < 16:
			raise "Not enough colors!"

		for c in range(1, 16):
			init_pair(c, c % COLORS, 0)

		me.default = color_pair(0)
		me.info = color_pair(14) | A_BLINK
		me.arena = color_pair(10) | A_BLINK
		me.pub = color_pair(15)
		me.priv = color_pair(2)
		me.rempriv = color_pair(10)
		me.freq = color_pair(11)
		me.chat = color_pair(9)
		me.mod = color_pair(12)
		me.sysop = color_pair(9) | A_BLINK



def start_curses():
	global wins

	wins.stdscr = curses.initscr()
	curses.cbreak()
	curses.noecho()
	wins.stdscr.keypad(1)
	wins.stdscr.nodelay(1)
	curses.start_color()
	init_wins()


def init_wins():
	global wins, editheight, pwidth

	h, w = wins.stdscr.getmaxyx()

	wins.stdscr.erase()

	wins.iwin = curses.newwin(editheight, w, h-editheight, 0)
	wins.stdscr.hline(h-editheight-1, 0, '-', w)

	if pwidth:
		wins.twin = curses.newwin(h-editheight-1, w-pwidth-1, 0, 0)
		wins.pwin = curses.newwin(h-editheight-1, pwidth, 0, w-pwidth)
		wins.stdscr.vline(0, w-pwidth-1, '|', h-editheight-1)
	else:
		wins.twin = curses.newwin(h-editheight-1, w, 0, 0)

	wins.stdscr.refresh()


def end_curses():
	global wins

	wins.stdscr.keypad(0)
	curses.echo()
	curses.nocbreak()
	curses.endwin()


def process_typed(line):
	global myname, colors, showtime, breakloop

	if line.startswith(';'):
		# chat message
		ns = line.find(';', 1)
		if ns == -1:
			send_line("SEND:CHAT:%s" % line[1:])
			add_line("1:%s> %s" % (myname, line[1:]), colors.chat)
		else:
			send_line("SEND:CHAT:%s" % line)
			add_line("%s:%s> %s" % (line[1:ns], myname, line[ns+1:]), colors.chat)
	elif line.startswith(':'):
		# priv msg
		ns = line.find(':', 1)
		if ns != -1:
			send_line("SEND:PRIV:%s:%s" % (line[1:ns], line[ns+1:]))
			add_line("%s to %s> %s" % (myname, line[1:ns], line[ns+1:]), colors.priv)
	elif line.startswith("'") or line.startswith('//') or line.startswith('"'):
		# team chat
		pass
	elif line.startswith('\\'):
		# mod chat
		send_line("SEND:MOD:%s" % line[1:])
		add_line("%s> %s" % (myname, line[1:]), colors.mod)
	elif line.startswith('?'):
		# possible command
		cmd = line[1:].lower()
		if cmd.startswith('exit') or cmd.startswith('quit'):
			breakloop = 1
		elif cmd.startswith('showtime'):
			showtime = 1
		elif cmd.startswith('hidetime'):
			showtime = 0
		elif cmd.startswith('go '):
			send_line("GO:%s" % cmd[3:])
		else:
			send_line("SEND:PUB:%s" % line)
		add_line("%s> %s" % (myname, line), colors.default)
	elif line.startswith('='):
		# freq change
		try:
			freq = int(line[1:])
			send_line("CHANGEFREQ:%d" % freq)
		except:
			add_line("Bad freq number", colors.info)
	else:
		# pub msg
		send_line("SEND:PUB:%s" % line)
		add_line("%s> %s" % (myname, line), colors.default)


def process_loginok(line):
	global myname
	add_line("Login ok. You are: %s" % line, 'info')
	myname = line


def process_loginbad(line):
	add_line("Bad login: %s" % line, 'info')


def process_inarena(line):
	global players, myname

	arena, freq = line.split(':')
	add_line("Entered arena: %s" % arena, 'info')

	players.clear()
	players[myname] = Player(myname, int(freq))


def process_player(line):
	global players

	name, ship, freq = line.split(':')
	add_line("Player here: %s" % name, 'info')
	players[name] = Player(name, int(freq))


def process_entering(line):
	global players

	name, ship, freq = line.split(':')
	add_line("Player entering: %s" % name, 'info')
	players[name] = Player(name, int(freq))


def process_leaving(line):
	global players

	name = line

	add_line("Player leaving: %s" % name, 'info')
	del players[name]


def process_msg(line):
	global colors

	type, rest = line.split(':', 1)
	if type == 'ARENA':
		msg = rest
		add_line(msg, colors.arena)
	elif type == 'PUB':
		name, msg = rest.split(':', 1)
		add_line('%s> %s' % (name, msg), colors.pub)
	elif type == 'PRIV':
		name, msg = rest.split(':', 1)
		add_line('%s> %s' % (name, msg), colors.priv)
	elif type == 'REMOTEPRIV':
		msg = rest
		add_line(rest, colors.rempriv)
	elif type == 'FREQ':
		name, msg = rest.split(':', 1)
		add_line('%s> %s' % (name, msg), colors.freq)
	elif type == 'CHAT':
		msg = rest
		add_line(msg, colors.chat)
	elif type == 'MOD':
		name, msg = rest.split(':', 1)
		add_line('%s> %s' % (name, msg), colors.mod)
	elif type == 'SYSOP':
		msg = rest
		add_line(msg, colors.sysop)
	else:
		add_line("Bad message subtype from server: '%s'" % type, colors.info)


def process_sfc(line):
	global colors

	player, ship, freq = line.split(':')
	ship = int(ship)
	freq = int(freq)

	if ship == 8:
		add_line('%s is now on freq %s as a spectator' % (player, freq), colors.info)
	else:
		add_line('%s is now on freq %s in a %s' % (player, freq, ship_names[ship]), colors.info)


dispatch = \
{
	'LOGINOK': process_loginok,
	'LOGINBAD': process_loginbad,
	'INARENA': process_inarena,
	'PLAYER': process_player,
	'ENTERING': process_entering,
	'LEAVING': process_leaving,
	'MSG': process_msg,
	'SHIPFREQCHANGE': process_sfc
}

def process_incoming(line):
	type, rest = line.split(':', 1)
	if dispatch.has_key(type):
		dispatch[type](rest)
	else:
		add_line("Bad message type from server: '%s'" % type, colors.info)


def send_line(line):
	global sock
	sock.sendall(line + '\n')


def add_line(line, color = None):
	global text, colors, scrollback, jumpback, newcrap
	if color == None:
		color = colors.default
	elif type(color) == type(''):
		color = getattr(colors, color)
	text.addline(Line(line, color))
	if jumpback:
		scrollback = 0
	elif scrollback > 0:
		newcrap += 1
		scrollback += 1


def display_players(scr):
	global players, colors

	if not scr:
		return

	h, w = scr.getmaxyx()
	scr.erase()

	names = players.keys()
	names.sort()

	y = 0
	for n in names:
		if y >= h:
			break
		scr.addstr(y, 0, n)
		y = y + 1

	scr.noutrefresh()


def process_key():
	global wins

	ch = wins.stdscr.getch()
	if ch == -1:
		pass
	elif ch == curses.KEY_RESIZE:
		init_wins()
	elif ch == 27:
		# get all chars of an escape sequence
		seq = []
		while ch != -1:
			seq.append(ch)
			ch = wins.stdscr.getch()
		f = bindings.getbinding(seq)
		if f:
			f()
	else:
		# hand off to bindings
		f = bindings.getbinding([ch])
		if f:
			f()
		elif ch in range(32, 127):
			global edit
			edit.insert(ch)


inbuf = ''
def read_data():
	global sock, inbuf

	try:
		r = sock.recv(1024)
		if r:
			inbuf = inbuf + r
		else:
			raise "Server disconnected!"

		lines = inbuf.splitlines(1)
		inbuf = ''
		for l in lines:
			if l.endswith('\n') or l.endswith('\r'):
				process_incoming(l.strip())
			else:
				inbuf = l
		del lines

	except:
		pass


def main_loop():
	global wins, text, edit, sock, breakloop

	breakloop = 0

	while 1:
		# show stuff
		text.display(wins.twin)
		display_players(wins.pwin)
		edit.display(wins.iwin)
		curses.doupdate()

		# do a select
		try:
			select.select([sys.stdin, sock], [], [], 10)
		except:
			pass

		# get key
		process_key()

		# read some data
		read_data()

		if breakloop:
			break


def process_config(file):
	global bindings
	global editheight, pwidth, showtime, indent, jumpback, keeplines

	bindings = Bindings()

	# default bindings
	bindings.bind('^J', bindings.finish_line)
	bindings.bind(curses.KEY_ENTER, bindings.finish_line)
	bindings.bind(curses.KEY_BACKSPACE, bindings.backspace)
	bindings.bind(curses.KEY_DC, bindings.delete)
	bindings.bind(curses.KEY_RIGHT, bindings.curs_right)
	bindings.bind(curses.KEY_LEFT, bindings.curs_left)
	bindings.bind(curses.KEY_UP, bindings.curs_up)
	bindings.bind(curses.KEY_DOWN, bindings.curs_down)
	bindings.bind('^A', bindings.curs_home)
	bindings.bind(curses.KEY_HOME, bindings.curs_home)
	bindings.bind(curses.KEY_END, bindings.curs_end)
	bindings.bind('^E', bindings.curs_end)
	bindings.bind(curses.KEY_PPAGE, bindings.scroll_up, 1)
	bindings.bind(curses.KEY_NPAGE, bindings.scroll_down, 1)
	bindings.bind('^U', bindings.kill_line)

	opts = {}

	# get 'bind' and the action funcs in there
	for d in dir(Bindings):
		opts[d] = getattr(bindings, d)

	# get the KEY_* constants in there
	for d in dir(curses):
		if d.startswith('KEY_'):
			opts[d] = getattr(curses, d)

	execfile(file, opts, opts)

	editheight = opts.get('editheight', 3)
	pwidth = opts.get('playerwidth', 15)
	indent = opts.get('indent', 2)
	showtime = opts.get('showtime', 1)
	jumpback = opts.get('jumpback', 0)
	keeplines = opts.get('keeplines', 1000)

	return opts


def main():
	global wins, text, edit, sock, players, myname, colors, scrollback
	global newcrap

	players = {}
	myname = ''

	# get config file
	if len(sys.argv) > 1:
		opts = process_config(sys.argv[1])
	elif os.environ.get('CCC_CONFIG_FILE'):
		opts = process_config(os.environ.get('CCC_CONFIG_FILE'))
	else:
		try:
			opts = process_config(os.environ.get('HOME') + '/.ccc-config')
		except:
			print "You must supply a configuration file."
			sys.exit(1)

	# check for required options
	if not opts.has_key('name') or not opts.has_key('password') or \
	   not opts.has_key('host') or not opts.has_key('port'):
		print "The config file must include a name, password, host, and port."
		sys.exit(2)

	scrollback = 0
	newcrap = 0

	wins = MyWindows()
	text = Text()
	edit = Edit()

	start_curses()

	bindings.bind(curses.erasechar(), bindings.backspace)
	bindings.bind(curses.killchar(), bindings.kill_line)

	try:

		colors = Colors()

		add_line("ccc - a console chat client <grelminar@yahoo.com>", 'info')
		add_line("Connecting to %s:%d as %s..." % \
			(opts['host'], opts['port'], opts['name']), 'info')
		text.display(wins.twin)
		curses.doupdate()

		sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
		sock.connect((opts['host'], opts['port']))
		sock.setblocking(0)

		add_line("Logging in...", 'info')
		text.display(wins.twin)
		curses.doupdate()

		send_line("LOGIN:1:%s:%s" % (opts['name'], opts['password']))

		main_loop()

	finally:
		end_curses()


if __name__ == '__main__':
	main()

