#!/usr/bin/env perl

use strict;
use warnings;
use POSIX qw(setuid setgid strftime);
use POE;
use POE::Kernel;
use POE::Component::IRC;
use POE::Component::IRC::Plugin::NickServID;
use IdaliusConfig;
use Plugin qw/load_plugin/;
use IRC::Utils qw(strip_color strip_formatting);

my $ignore_suffix = "_yes_really_even_from_ignored_nicks";
my $config_file = "bot.conf";
my $config = IdaliusConfig::parse_config($config_file);
my %laststrike = ();
my $ping_delay = 300;
my %commands = ();

sub log_info {
	# FIXME direct to a log file instead of stdout
	my $stamp = strftime("%Y-%m-%d %H:%M:%S %z", localtime);
	print "$stamp | @_\n";
}

Plugin::set_load_callback(\&module_loaded_callback);

load_configure_all_plugins();

$| = 1;

# New PoCo-IRC object
my $irc = POE::Component::IRC->spawn(
	UseSSL => $config->{_}->{usessl},
	SSLCert => $config->{_}->{sslcert},
	SSLKey => $config->{_}->{sslkey},
	nick => $config->{_}->{nick},
	ircname => $config->{_}->{ircname},
	port    => $config->{_}->{port},
	server  => $config->{_}->{server},
	username => $config->{_}->{username},
) or die "Failed to create new PoCo-IRC: $!";

# Plugins
$config->{_}->{password} and $irc->plugin_add(
	'NickServID',
	POE::Component::IRC::Plugin::NickServID->new(
		Password => $config->{_}->{password}
	));

POE::Session->create(
	package_states => [
		main => [ qw(
			_default
			_start
			irc_001
			irc_002
			irc_003
			irc_004
			irc_251
			irc_252
			irc_253
			irc_254
			irc_255
			irc_302
			irc_331
			irc_332
			irc_372
			irc_375
			irc_376
			irc_kick
			irc_ctcp_action
			irc_public
			irc_notice
			irc_topic
			irc_ping
			irc_msg
			irc_join
			irc_part
			irc_invite
			irc_nick
			irc_disconnected
			irc_error
			irc_socketerr
			irc_delay_set
			irc_delay_removed
			custom_ping) ],
	],
	heap => { irc => $irc },
);

drop_priv();

$poe_kernel->run();


################################################################################
# Helpers and module framework
sub load_configure_all_plugins {
	eval {
		for my $module (@{$config->{_}->{plugins}}) {
			Plugin::load_plugin(\&log_info, $config->{_}, $module);
		}
		1;
	} or do {
		log_info "Error: failed to load module: $@";
		die;
	};
}

sub module_loaded_callback {
	my ($module) = @_;

	$module->configure(
		\&register_command,
		\&run_command,
		$config->{$module},
		$config->{_});
}

sub module_is_enabled {
	my $module = $_[0];

	return grep {$_ eq $module} @{$config->{_}->{active_plugins}};
}

# Register a command name to a certain sub
sub register_command {
	my ($owner, $command, $action) = @_;
	$command = lc $command;
	log_info "Registering command: $command (from $owner)";
	$commands{$owner}{$command} = $action;
}

sub run_command {
	my ($command_string, $who, $where, $ided, $no_reenter) = @_;
	my @arguments;
	my $owner;
	my $command_verbatim;
	my $command;

	OUTER: for my $o (keys %commands) {
		next unless module_is_enabled($o);
		for my $c (keys %{$commands{$o}}) {
			if (($command_verbatim) = $command_string =~ m/^(\Q$c\E( |$))/i) {
				$command = lc $c;
				$owner = $o;
				last OUTER;
			}
		}
	}

	return "No such command" unless $command;

	my $rest = (split "\Q$command_verbatim", $command_string, 2)[1];
	@arguments = split /\s+/, $rest if $rest;

	my $action = $commands{$owner}{$command};
	return $action->($irc, \&log_info, $who, $where, $ided, $rest,
		sub { push @$no_reenter, $owner; },
		@arguments);
}

my $watchdog_alarm;
# Handler for userhost response. We use userhost requests as a crude form
# of backwards ping/keepalive
sub irc_302 {
	my ($poek) = @_;
	# Cancel the watchdog fail timer
	$poek->delay_remove($watchdog_alarm) if $watchdog_alarm;
	log_info "Watchdog received pat";
}

sub ping_fail {
	log_info "Error: heartbeat failed to pat watchdog. Exiting";
	exit(1);
}

# Send a harmless client->server message as a form of heartbeat to allegedly
# help POE realise when a connection is down. It seems not to realise a
# connection has fallen over otherwise.
sub custom_ping {
	my ($poek) = $_[KERNEL];
	# Send a ping and schedule the next one
	$irc->yield(userhost => $irc->nick_name());
	$poek->delay(custom_ping => $ping_delay);

	# Set a watchdog for twice the ping interval
	$watchdog_alarm = $poek->delay(ping_fail => 2 * $ping_delay);
}

sub drop_priv {
	setgid($config->{_}->{gid}) or die "Failed to setgid: $!\n";
	setuid($config->{_}->{uid}) or die "Failed to setuid: $!\n";
}

# Add a strike against a nick for module flood protection
# This differs from antiflood.pm in that it is used only for when users have
# triggered a response from the bot.
sub strike_add {
	my $strike_count = 14;
	my $strike_period = 45;

	my ($who, $channel) = @_;
	my $nick = (split /!/, $who)[0];
	my $now = time();
	push @{$laststrike{$who}}, $now;
	if (@{$laststrike{$who}} >= $strike_count) {
		@{$laststrike{$who}} = splice @{$laststrike{$who}}, 1, $strike_count - 1;
		my $first = @{$laststrike{$who}}[0];
		if ($now - $first <= $strike_period) {
			log_info "Ignoring $who because of command flood";
			$irc->yield(privmsg => $channel => "$nick: I'm ignoring you now, you've caused me to talk too much");
			push @{$config->{_}->{ignore}}, $who;
		}
	}
}

sub should_ignore {
	my ($who) = @_;

	# Short circuit on non-user messages (undef is used for server msgs)
	return unless $who;

	for my $mask (@{$config->{_}->{ignore}}) {
		my $expr = $mask;
		$expr =~ s/([^[:alnum:]\*])/$1/g;
		$expr =~ s/\*/.*/g;
		if ($who =~ /^$expr$/) {
			return 1;
		}
	}
	return;
}

sub reconnect {
	my $reconnect_delay = 20;

	log_info("Reconnecting in $reconnect_delay seconds");
	sleep($reconnect_delay);

	$irc->yield(connect => { });
}


################################################################################
# Plugin event handling helpers
sub handle_common {
	my ($message_type, $who, $where, $what, $ided) = @_;
	my $nick = (split /!/, $who)[0];
	my $channel = $where->[0];
	my $output;

	my @no_reenter = ();

	$what =~ s/\s+$//g;

	# Firstly, trigger commands
	my $stripped_what = strip_color(strip_formatting($what));
	my $no_prefix_what = $stripped_what;
	my $current_nick = $irc->nick_name();
	if (!should_ignore($who) && ($config->{_}->{prefix_nick} && $no_prefix_what =~ s/^\Q$current_nick\E[:,]\s+//g ||
	    ($config->{_}->{prefix} && $no_prefix_what =~ s/^\Q$config->{_}->{prefix}//))) {
		$output = run_command($no_prefix_what, $who, $where, $ided, \@no_reenter);
		$irc->yield(privmsg => $where => $output) if $output;
		strike_add($who, $channel) if $output;
	}

	# Secondly, trigger non-command handlers
	trigger_modules($message_type, $who, $where, \@no_reenter, ($who, $where, $what, $stripped_what));

	return;
}

# Trigger applicable non-command-bound handlers in any active modules for
# a given message type, passing them only the given arguments
sub trigger_modules {
	my ($message_type, $who, $where, $no_reenter, @arguments) = @_;

	for my $handler (handlers_for($message_type, $who, $no_reenter)) {
		my @base_args = (\&log_info);
		push @base_args, @arguments;
		push @base_args, $irc;
		my $output = $handler->(@base_args);
		if ($output and $where) {
			$irc->yield(privmsg => $where => $output);
			strike_add($who, $where->[0]);
		}
	}
	return;
}

# Return a list of subs capable of handling the given message type for a nick
# excluding those modules that have asked not to be reentered for this message
sub handlers_for {
	my ($message_type, $who, $no_reenter) = @_;
	my @handlers = ();

	$message_type = "on_$message_type";
	for my $module (@{$config->{_}->{active_plugins}}) {
		if (!(grep {$_ eq $module} @$no_reenter) and module_is_enabled($module)) {
			if (!should_ignore($who) and $module->can($message_type)) {
				# Leave message type unchanged
			} elsif ($module->can($message_type.$ignore_suffix)) {
				$message_type = $message_type.$ignore_suffix;
			} else {
				# No handler
				next;
			}
			push @handlers, sub { $module->$message_type(@_); };
		}
	}
	return @handlers;
}


###############################################################################
# Begin internal/core handlers
sub _start {
	my $heap = $_[HEAP];
	my $irc = $heap->{irc};
	$irc->yield(register => 'all');
	$irc->yield(connect => { });
	return;
}

sub irc_001 {
	my ($poek, $server, $message) = @_[KERNEL, ARG0, ARG1];
	trigger_modules("001_welcome", undef, undef, [], ($server, $message));

	# FIXME move to forward ping module
	$poek->delay(custom_ping => $ping_delay);
	return;
}

# 002 (your host)
sub irc_002 {
	my $message = $_[ARG1];
	trigger_modules("002_your_host", undef, undef, [], ($message));
	return;
}

# 003 (created)
sub irc_003 {
	my $message = $_[ARG1];
	trigger_modules("003_created", undef, undef, [], ($message));
	return;
}

# 004 (myinfo)
sub irc_004 {
	my $message = $_[ARG1];
	trigger_modules("004_my_info", undef, undef, [], ($message));
	return;
}

# 251 (luserclient)
sub irc_251 {
	my $message = $_[ARG1];
	trigger_modules("251_user_client", undef, undef, [], ($message));
	return;
}

# 252 (luserop)
sub irc_252 {
	my ($count, $message) = @{$_[ARG2]};
	trigger_modules("252_user_op", undef, undef, [], ($count, $message));
	return;
}

# 253 (luserunknown)
sub irc_253 {
	my ($count, $message) = @{$_[ARG2]};
	trigger_modules("253_user_unknown", undef, undef, [], ($count, $message));
	return;
}

# 254 (luserchannels)
sub irc_254 {
	my ($count, $message) = @{$_[ARG2]};
	trigger_modules("254_user_channels", undef, undef, [], ($count, $message));
	return;
}

# 255 (luserme)
sub irc_255 {
	my ($message) = $_[ARG1];
	trigger_modules("255_user_me", undef, undef, [], ($message));
	return;
}

# 331 (rpl_notopic)
# Sent in response to topic query
sub irc_331 {
	my ($where) = $_[ARG1];
	trigger_modules("331_rpl_notopic", undef, $where, [], ($where));
	return;
}

# 332 (rpl_topic)
# Sent in response to topic query
sub irc_332 {
	my ($where, $topic) = @{$_[ARG2]};
	trigger_modules("332_rpl_topic", undef, undef, [], ($where, $topic));
	return;
}

# 372 (MOTD content)
sub irc_372 {
	my ($server, $motd) = @_[ARG0..ARG1];
	trigger_modules("372_motd_content", undef, undef, [], ($server, $motd));
	return;
}

# 375 (MOTD begin)
sub irc_375 {
	my ($server, $message) = @_[ARG0..ARG1];
	trigger_modules("375_motd_begin", undef, undef, [], ($server, $message));
	return;
}

# 376 (MOTD end)
sub irc_376 {
	my ($server, $message) = @_[ARG0..ARG1];
	trigger_modules("376_motd_end", undef, undef, [], ($server, $message));
	return;
}


sub irc_ctcp_action {
	my ($sender, $who, $where, $what) = @_[SENDER, ARG0 .. ARG2];
	my $nick = ( split /!/, $who )[0];
	my $channel = $where->[0];

	handle_common("action", $who, $where, $what);
	return;
}

sub irc_public {
	my ($who, $where, $what, $ided) = @_[ARG0 .. ARG3];
	my $nick = ( split /!/, $who )[0];
	my $channel = $where->[0];
	handle_common("message", $who, $where, $what, $ided);
	return;
}

sub irc_join {
	my ($who, $channel) = @_[ARG0 .. ARG1];
	trigger_modules("join", $who, $channel, [], ($who, $channel));
	return;
}

sub irc_part {
	my ($who, $channel, $why) = @_[ARG0 .. ARG2];
	my $nick = ( split /!/, $who )[0];
	my @where = ($channel);

	trigger_modules("part", $who, $channel, [], ($who, $channel, $why));
	return;
}

sub irc_kick {
	my ($kicker, $channel, $kickee, $reason) = @_[ARG0 .. ARG3];
	trigger_modules("kick", $kicker, $channel, [], ($kicker, $channel, $kickee, $reason));
	return;
}

sub irc_nick {
	my ($who, $new_nick) = @_[ARG0 .. ARG1];
	trigger_modules("nick", $who, undef, [], ($who, $new_nick));
	return;
}

sub irc_invite {
	my ($who, $where) = @_[ARG0 .. ARG1];
	trigger_modules("invite", $who, undef, [], ($who, $where));
	return;
}

# FIXME these need implementing even if just for logging:
# irc_registered
# irc_shutdown
# irc_connected
# irc_ctcp_*
# irc_ctcpreply_*
# irc_disconnected
# irc_error
# irc_mode
# irc_quit
# irc_socketerr
# irc_whois
# irc_whowas

sub irc_notice {
	my ($who, $where, $message) = @_[ARG0 .. ARG2];
	trigger_modules("notice", $who, $where, [], ($who, $where->[0], $message));
	return;
}

sub irc_topic {
	my ($who, $where, $topic) = @_[ARG0 .. ARG2];
	trigger_modules("topic", $who, undef, [], ($who, $where, $topic));
	return;
}

sub irc_ping {
	my $server = $_[ARG0];
	trigger_modules("ping", undef, undef, [], ($server));
	return;
}

sub irc_msg {
	my ($who, $to, $what, $ided) = @_[ARG0 .. ARG3];
	my $nick = (split /!/, $who)[0];
	my $stripped_what = strip_color(strip_formatting($what));
	my $output = run_command($stripped_what, $who, $nick, $ided);
	$irc->yield(privmsg => $nick => $output) if $output;

	trigger_modules("privmsg", $who, undef, [], ($who, $to, $what, $stripped_what));

	return;
}

###############################################################################

sub irc_disconnected {
	_default(@_); # Dump the message
	my $reconnect_delay = 20;

	$config = IdaliusConfig::parse_config($config_file);
	load_configure_all_plugins();
	reconnect();
}

sub irc_error {
	_default(@_); # Dump the message
	reconnect();
}

sub irc_socketerr {
	_default(@_); # Dump the message
	reconnect();
}

sub irc_delay_set {
	# nop, silence this
}
sub irc_delay_removed {
	# nop, silence this
}

sub _default {
	my ($event, $args) = @_[ARG0 .. $#_];

	# exit early unless in debug mode
	return unless $config->{_}->{log_debug};

	my @output = ( "$event: " );

	for my $arg (@$args) {
		if ( ref $arg eq 'ARRAY' ) {
			push( @output, '[' . join(', ', @$arg ) . ']' );
		}
		else {
			push ( @output, "'$arg'" );
		}
	}
	log_info(join ' ', @output);
	return;
}