#!/usr/bin/env ruby
# coding: utf-8
#
# ZyklonB pomodoro plugin
#
# Copyright 2015 Přemysl Janouch
# See the file LICENSE for licensing information.
#

# --- Simple event loop --------------------------------------------------------

# This is more or less a straight-forward port of my C event loop.  It's a bit
# unfortunate that I really have to implement all this in order to get some
# basic asynchronicity but at least I get to exercise my Ruby.

class TimerEvent
	attr_accessor :index, :when, :callback

	def initialize (callback)
		raise ArgumentError unless callback.is_a? Proc

		@index = nil
		@when = nil
		@callback = callback
	end

	def active?
		@index != nil
	end

	def until
		return @when - Time.new
	end
end

class IOEvent
	READ  = 1 << 0
	WRITE = 1 << 1

	attr_accessor :read_index, :write_index, :io, :callback

	def initialize (io, callback)
		raise ArgumentError unless callback.is_a? Proc

		@read_index = nil
		@write_index = nil
		@io = io
		@callback = callback
	end
end

class EventLoop
	def initialize
		@running = false
		@timers = []
		@readers = []
		@writers = []
		@io_to_event = {}
	end

	def set_timer (timer, timeout)
		raise ArgumentError unless timer.is_a? TimerEvent

		timer.when = Time.now + timeout
		if timer.index
			heapify_down timer.index
			heapify_up timer.index
		else
			timer.index = @timers.size
			@timers.push timer
			heapify_up timer.index
		end
	end

	def reset_timer (timer)
		raise ArgumentError unless timer.is_a? TimerEvent
		remove_timer_at timer.index if timer.index
	end

	def set_io (io_event, events)
		raise ArgumentError unless io_event.is_a? IOEvent
		raise ArgumentError unless events.is_a? Numeric

		reset_io io_event

		@io_to_event[io_event.io] = io_event
		if events & IOEvent::READ
			io_event.read_index = @readers.size
			@readers.push io_event.io
		end
		if events & IOEvent::WRITE
			io_event.read_index = @writers.size
			@writers.push io_event.io
		end
	end

	def reset_io (io_event)
		raise ArgumentError unless io_event.is_a? IOEvent

		@readers.delete_at io_event.read_index  if io_event.read_index
		@writers.delete_at io_event.write_index if io_event.write_index

		io_event.read_index = nil
		io_event.write_index = nil

		@io_to_event.delete io_event.io
	end

	def run
		@running = true
		while @running do one_iteration end
	end

	def quit
		@running = false
	end

private
	def one_iteration
		rs, ws, = IO.select @readers, @writers, [], nearest_timeout
		dispatch_timers
		(Array(rs) | Array(ws)).each do |io|
			@io_to_event[io].callback.call io
		end
	end

	def dispatch_timers
		now = Time.new
		while not @timers.empty? and @timers[0].when <= now do
			@timers[0].callback.call
			remove_timer_at 0
		end
	end

	def nearest_timeout
		return nil if @timers.empty?
		timeout = @timers[0].until
		if timeout < 0 then 0 else timeout end
	end

	def remove_timer_at (index)
		@timers[index].index = nil
		moved = @timers.pop
		return if index == @timers.size

		@timers[index] = moved
		@timers[index].index = index
		heapify_down index
	end

	def swap_timers (a, b)
		@timers[a], @timers[b] = @timers[b], @timers[a]
		@timers[a].index = a
		@timers[b].index = b
	end

	def heapify_up (index)
		while index != 0 do
			parent = (index - 1) / 2
			break if @timers[parent].when <= @timers[index].when
			swap_timers index, parent
			index = parent
		end
	end

	def heapify_down (index)
		loop do
			parent = index
			left  = 2 * index + 1
			right = 2 * index + 2

			lowest = parent
			lowest = left  if left  < @timers.size and
				@timers[left] .when < @timers[lowest].when
			lowest = right if right < @timers.size and
				@timers[right].when < @timers[lowest].when
			break if parent == lowest

			swap_timers lowest, parent
			index = lowest
		end
	end
end

# --- IRC protocol -------------------------------------------------------------

$stdin.set_encoding 'ASCII-8BIT'
$stdout.set_encoding 'ASCII-8BIT'

$stdin.sync = true
$stdout.sync = true

$/ = "\r\n"
$\ = "\r\n"

RE_MSG = /(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(?: +(.*))?$/
RE_ARGS = /:?((?<=:).*|[^ ]+) */

def parse (line)
	m = line.match RE_MSG
	return nil if not m

	nick, user, host, command, args = *m.captures
	args = if args then args.scan(RE_ARGS).flatten else [] end
	[nick, user, host, command, args]
end

def bot_print (what)
	print "ZYKLONB print :#{what}"
end

# --- Initialization -----------------------------------------------------------

# We can only read in configuration from here so far
# To read it from anywhere else, it has to be done asynchronously
$config = {}
[:prefix].each do |name|
	print "ZYKLONB get_config :#{name}"
	_, _, _, _, args = *parse($stdin.gets.chomp)
	$config[name] = args[0]
end

print "ZYKLONB register"

# --- Plugin logic -------------------------------------------------------------

# FIXME: this needs a major refactor as it doesn't make much sense at all

class MessageMeta < Struct.new(:nick, :user, :host, :channel, :ctx, :quote)
	def respond (message)
		print "PRIVMSG #{ctx} :#{quote}#{message}"
	end
end

class Context
	attr_accessor :nick, :ctx

	def initialize (meta)
		@nick = meta.nick
		@ctx = meta.ctx
	end

	def == (other)
		self.class == other.class \
			and other.nick == @nick \
			and other.ctx  == @ctx
	end

	alias eql? ==

	def hash
		@nick.hash ^ @ctx.hash
	end
end

class PomodoroTimer
	def initialize (context)
		@ctx = context.ctx
		@nicks = [context.nick]

		@timer_work = TimerEvent.new(lambda { on_work })
		@timer_rest = TimerEvent.new(lambda { on_rest })

		on_work
	end

	def inform (message)
		# FIXME: it tells the nick even in PM's
		quote = "#{@nicks.join(" ")}: "
		print "PRIVMSG #{@ctx} :#{quote}#{message}"
	end

	def on_work
		inform "work now!"
		$loop.set_timer @timer_rest, 25 * 60
	end

	def on_rest
		inform "rest now!"
		$loop.set_timer @timer_work, 5 * 60
	end

	def join (meta)
		return if @nicks.include? meta.nick

		meta.respond "you have joined their pomodoro"
		@nicks |= [meta.nick]
	end

	def part (meta, requested)
		return if not @nicks.include? meta.nick

		if requested
			meta.respond "you have stopped your pomodoro"
		end

		@nicks -= [meta.nick]
		if @nicks.empty?
			$loop.reset_timer @timer_work
			$loop.reset_timer @timer_rest
		end
	end

	def status (meta)
		return if not @nicks.include? meta.nick

		if @timer_rest.active?
			till = @timer_rest.until
			meta.respond "working, #{(till / 60).to_i} minutes, " +
				"#{(till % 60).to_i} seconds until rest"
		end
		if @timer_work.active?
			till = @timer_work.until
			meta.respond "resting, #{(till / 60).to_i} minutes, " +
				"#{(till % 60).to_i} seconds until work"
		end
	end
end

class Pomodoro
	KEYWORD = "pomodoro"

	def initialize
		@timers = {}
	end

	def on_help (meta, args)
		meta.respond "usage: #{KEYWORD} { start | stop | join <nick> | status }"
	end

	def on_start (meta, args)
		if args.size != 0
			meta.respond "usage: #{KEYWORD} start"
			return
		end

		context = Context.new meta
		if @timers[context]
			meta.respond "you already have a timer running here"
		else
			@timers[context] = PomodoroTimer.new meta
		end
	end

	def on_join (meta, args)
		if args.size != 1
			meta.respond "usage: #{KEYWORD} join <nick>"
			return
		end

		context = Context.new meta
		if @timers[context]
			meta.respond "you already have a timer running here"
			return
		end

		joined_context = Context.new meta
		joined_context.nick = args.shift
		timer = @timers[joined_context]
		if not timer
			meta.respond "that person doesn't have a timer here"
		else
			timer.join meta
			@timers[context] = timer
		end
	end

	def on_stop (meta, args)
		if args.size != 0
			meta.respond "usage: #{KEYWORD} stop"
			return
		end

		context = Context.new meta
		timer = @timers[context]
		if not timer
			meta.respond "you don't have a timer running here"
		else
			timer.part meta, true
			@timers.delete context
		end
	end

	def on_status (meta, args)
		if args.size != 0
			meta.respond "usage: #{KEYWORD} status"
			return
		end

		timer = @timers[Context.new meta]
		if not timer
			meta.respond "you don't have a timer running here"
		else
			timer.status meta
		end
	end

	def process_command (meta, msg)
		args = msg.split
		return if args.shift != KEYWORD

		method = "on_#{args.shift}"
		send method, meta, args if respond_to? method
	end

	def on_server_nick (meta, command, args)
		# TODO: either handle this properly...
		happened = false
		@timers.keys.each do |key|
			next if key.nick != meta.nick
			@timers[key].part meta, false
			@timers.delete key
			happened = true
		end
		if happened
			# TODO: ...or at least inform the user via his new nick
		end
	end

	def on_server_part (meta, command, args)
		# TODO: instead of cancelling the user's pomodoros, either redirect
		#   them to PM's and later upon rejoining undo the redirection...
		context = Context.new(meta)
		context.ctx = meta.channel
		if @timers.include? context
			# TODO: ...or at least inform the user about the cancellation
			@timers[context].part meta, false
			@timers.delete context
		end
	end

	def on_server_quit (meta, command, args)
		@timers.keys.each do |key|
			next if key.nick != meta.nick
			@timers[key].part meta, false
			@timers.delete key
		end
	end

	def process (meta, command, args)
		method = "on_server_#{command.downcase}"
		send method, meta, command, args if respond_to? method
	end
end

# --- IRC message processing ---------------------------------------------------

$handlers = [Pomodoro.new]
def process_line (line)
	msg = parse line
	return if not msg

	nick, user, host, command, args = *msg

	context = nick
	quote = ""
	channel = nil

	if args.size >= 1 and args[0].start_with? ?#, ?+, ?&, ?!
		case command
		when "PRIVMSG", "NOTICE", "JOIN"
			context = args[0]
			quote = "#{nick}: "
			channel = args[0]
		when "PART"
			channel = args[0]
		end
	end

	# Handle any IRC message
	meta = MessageMeta.new(nick, user, host, channel, context, quote).freeze
	$handlers.each do |handler|
		handler.process meta, command, args
	end

	# Handle pre-processed bot commands
	if command == 'PRIVMSG' and args.size >= 2
		msg = args[1]
		return unless msg.start_with? $config[:prefix]
		$handlers.each do |handler|
			handler.process_command meta, msg[$config[:prefix].size..-1]
		end
	end
end

buffer = ""
stdin_io = IOEvent.new($stdin, lambda do |io|
	begin
		buffer << io.read_nonblock(4096)
		lines = buffer.split $/, -1
		buffer = lines.pop
		lines.each { |line| process_line line }
	rescue EOFError
		$loop.quit
	rescue IO::WaitReadable
		# Ignore
	end
end)

$loop = EventLoop.new
$loop.set_io stdin_io, IOEvent::READ
$loop.run