package Net::MCMP;

use strict;
use warnings;

use HTTP::Request;
use LWP::UserAgent;

our $VERSION = '0.05';

sub new {
	my ( $class, $ref ) = @_;

	unless ( exists $ref->{uri} ) {
		die 'missing uri';
	}

	$ref->{uri} =~ s/\s+//g;

	my $self = { _uri => $ref->{uri} };

	if ( exists $ref->{debug} && $ref->{debug} ) {
		$self->{_debug} = 1;
	}

	bless $self, $class;
	return $self;
}

sub uri {
	return $_[0]->{_uri};
}

sub debug {
	return ($_[0]->{_debug} || $ENV{MCMP_TRACE} || undef);
}

use constant DEFAULT_MCMP_CONFIG => {
	Balancer            => 'mycluster',
	StickySession       => 'yes',
	StickySessionCookie => 'JSESSIONID',
	StickySessionPath   => 'jsessionid',
	StickySessionRemove => 'no',
	StickySessionForce  => 'yes',
	WaitWorker          => 0,
	MaxAttempts         => 1,
	JvmRoute            => undef,
	Domain              => 'mycluster',
	Host                => 'localhost',
	Port                => '8009',
	Type                => 'ajp',
	FlushPackets        => 'off',
	FlushWait           => 1,
	Ping                => 10,
	Smax                => undef,
	Ttl                 => 60,
	Timeout             => 0,
	Context             => undef,
	Alias               => undef,
};

# FROM: https://community.jboss.org/wiki/Mod-Clusternodebalancer
#
# JvmRoute: See http://wiki.jboss.org/wiki/Mod-ClusterManagementProtocol Default: Mandatory
# Domain: See http://wiki.jboss.org/wiki/Mod-ClusterManagementProtocol Default: "" empty string
# Host: See http://wiki.jboss.org/wiki/Mod-ClusterManagementProtocol Default: "localhost"
# Port: See http://wiki.jboss.org/wiki/Mod-ClusterManagementProtocol Default: "8009"
# Type: See http://wiki.jboss.org/wiki/Mod-ClusterManagementProtocol Default: "ajp"
# flushpackets: Tell how to flush the packets. On: Send immediately, Auto wait for flushwait time before sending, Off don't flush. Default: "Off"
# flushwait: Time to wait before flushing. Value in milliseconds. Default: 10
# ping: Time to wait for a pong answer to a ping. 0 means we don't try to ping before sending. Value in secondes Default: 10
# smax: soft max inactive connection over that limit after ttl are closed. Default depends on the mpm configuration (See below for more information)
# ttl: max time in seconds to life for connection above smax. Default 60 seconds.
# Timeout: Max time httpd will wait for the backend connection. Default 0 no timeout value in seconds.

# Balancer: Name of the balancer. max size: 40 Default: "mycluster"
# StickySession: Yes: use JVMRoute to stick a request to a node, No: ignore JVMRoute. Default: "Yes"
# StickySessionCookie: Name of the cookie containing the sessionid. Max size: 30 Default: "JSESSIONID"
# StickySessionPath: Name of the parametre containing the sessionid. Max size: 30. Default: "jsessionid"
# StickySessionRemove: Yes: remove the sessionid (cookie or parameter) when the request can't be routed to the right node. No: send it anyway. Default: "No"
# StickySessionForce: Yes: Return an error if the request can't be routed according to JVMRoute, No: Route it to another node. Default: "Yes"
# WaitWorker: value in seconds: time to wait for an available worker. Default: "0" no wait.
# Maxattempts: value: number of attemps to send the request to the backend server. Default: "1".

sub config {
	my ( $self, $ref ) = @_;

	unless ( ref $ref eq 'HASH' ) {
		die 'passed reference must be a HASH reference';
	}

	foreach my $key ( keys %{ $self->DEFAULT_MCMP_CONFIG } ) {
		unless ( defined $ref->{$key} ) {
			$ref->{$key} = $self->DEFAULT_MCMP_CONFIG->{$key};
		}
	}

	unless ( $ref->{JvmRoute} ) {
		die 'JvmRoute is missing';
	}

	if ( length $ref->{Balancer} > 40 ) {
		die 'Balancer cannot exceed 40 characters';
	}

	if ( $ref->{StickySession} !~ /^(yes|no)$/i ) {
		die 'invalid StickySession value, should be yes|no';
	}

	if ( length $ref->{StickySessionCookie} > 30 ) {
		die 'StickySessionCookie cannot exceed 30 characters';
	}

	if ( length $ref->{StickySessionPath} > 30 ) {
		die 'StickySessionCookie cannot exceed 30 characters';
	}

	if ( $ref->{StickySessionRemove} !~ /^(yes|no)$/i ) {
		die 'invalid StickySessionRemove value, should be yes|no';
	}

	if ( $ref->{StickySessionForce} !~ /^(yes|no)$/i ) {
		die 'invalid StickySessionForce value, should be yes|no';
	}

	if ( $ref->{WaitWorker} < 0 ) {
		die 'WaitWorker cannot be less than 0';
	}

	if ( $ref->{MaxAttempts} < 1 ) {
		die 'MaxAttempts cannot be less than 1';
	}

	if ( length $ref->{JvmRoute} > 80 ) {
		die 'JvmRoute cannot exceed 80 characters';
	}

	if ( length $ref->{Domain} > 20 ) {
		die 'Domain cannot exceed 20 characters';
	}

	if ( length $ref->{Host} > 64 ) {
		die 'Host cannot exceed 64 characters';
	}

	if ( length $ref->{Port} < 0 || length $ref->{Port} > 65545 ) {
		die 'Port must be between 0 and 65545';
	}

	if ( $ref->{Type} !~ /^(https|http|ajp)$/i ) {
		die 'invalid Type value, should be https|http|ajp';
	}

	if ( $ref->{FlushPackets} !~ /^(on|off|auto)$/i ) {
		die 'invalid FlushPackets value, should be on|off|auto';
	}

	if ( $ref->{FlushWait} < 0 ) {
		die 'FlushWait cannot be less than 0';
	}

	if ( $ref->{Ping} < 0 ) {
		die 'Ping cannot be less than 0';
	}

	if ( $ref->{Ttl} < 0 ) {
		die 'Ttl cannot be less than 0';
	}

	if ( $ref->{Timeout} < 0 ) {
		die 'Timeout cannot be less than 0';
	}

	return $self->request( 'CONFIG', $self->uri, $ref );

}

use constant DEFAULT_MCMP_APP => {
	JvmRoute => undef,
	Context  => undef,
	Alias    => undef,
};

sub enable_app {
	shift->_app( 'ENABLE-APP', @_ );
}

sub disable_app {
	shift->_app( 'DISABLE-APP', @_ );
}

sub stop_app {
	shift->_app( 'STOP-APP', @_ );
}

sub remove_app {
	shift->_app( 'REMOVE-APP', @_ );
}

sub _app {
	my ( $self, $method, $ref ) = @_;

	unless ( ref $ref eq 'HASH' ) {
		die 'passed reference must be a HASH reference';
	}

	foreach my $key ( keys %{ $self->DEFAULT_MCMP_CONFIG } ) {
		unless ( defined $ref->{$key} ) {
			$ref->{$key} = $self->DEFAULT_MCMP_CONFIG->{$key};
		}
	}

	unless ( $ref->{JvmRoute} ) {
		die 'JvmRoute is missing';
	}

	unless ( $ref->{Context} ) {
		die 'Context is missing';
	}

	unless ( $ref->{Alias} ) {
		die 'Alias is missing';
	}

	return $self->request( $method, $self->uri, $ref );
}

sub enable_route {
	shift->_route( 'ENABLE-APP', @_ );
}

sub disable_route {
	shift->_route( 'DISABLE-APP', @_ );
}

sub stop_route {
	shift->_route( 'STOP-APP', @_ );
}

sub remove_route {
	shift->_route( 'REMOVE-APP', @_ );
}

sub _route {
	my ( $self, $method, $ref ) = @_;

	unless ( ref $ref eq 'HASH' ) {
		die 'passed reference must be a HASH reference';
	}

	unless ( $ref->{JvmRoute} ) {
		die 'JvmRoute is missing';
	}

	return $self->request( $method, $self->uri . '/*', $ref );
}

sub status {
	my ( $self, $ref ) = @_;

	unless ( ref $ref eq 'HASH' ) {
		die 'passed reference must be a HASH reference';
	}

	unless ( $ref->{JvmRoute} ) {
		die 'JvmRoute is missing';
	}

	unless ( $ref->{Load} ) {
		die 'Load is missing';
	}
	return $self->request( 'STATUS', $self->uri, $ref );
}

sub ping {
	my ( $self, $ref ) = @_;

	unless ( ref $ref eq 'HASH' ) {
		die 'passed reference must be a HASH reference';
	}

	unless ( $ref->{JvmRoute} ) {
		die 'JvmRoute is missing';
	}

	return $self->request( 'PING', $self->uri, $ref );
}

sub dump {
	my ($self) = @_;

	return $self->request( 'DUMP', $self->uri );
}

sub info {
	my ($self) = @_;

	return $self->request( 'INFO', $self->uri );
}

sub request {
	my ( $self, $method, $uri, $params ) = @_;

	unless ( exists $self->{_ua} ) {
		$self->{_ua} = LWP::UserAgent->new;
	}

	my $ua   = $self->{_ua};
	my $path = URI->new();
	if ( defined $params ) {
		foreach my $key ( qw/Context Alias/ ) {
			next unless defined $params->{$key};
			$params->{$key} =~ s/\s+//g;
		}
		
		$path->query_form($params);
	}

	if ( $self->debug ) {
		if ( $path->query ) {
			warn "Making a $method request to $uri with these params: "
			  . $path->query;
		}
		else {
			warn "Making a $method request to $uri";
		}

	}
	my $req = HTTP::Request->new( $method, $uri, undef, $path->query || undef );
	$req->header( 'Accept' => 'text/plain' );
	my $response = $ua->request($req);

	if ( $response->is_success ) {
		if ( $response->content ) {

			if ( $self->debug ) {
				warn "RESPONSE: " . $response->content;
			}

			if ( $method eq 'DUMP' ) {

				# dump parser
				return $response->content;
			}
			elsif ( $method eq 'INFO' ) {

				# info parser
				return $response->content;
			}
			else {
				my $resp_uri        = URI->new( '?' . $response->content );
				my %parsed_response = $resp_uri->query_form;

				# fix return inconsistencies
				foreach my $key ( keys %parsed_response ) {
					if ( $key =~ /jvmroute/i ) {
						$parsed_response{JvmRoute} = $parsed_response{$key};
						delete $parsed_response{$key};
					}
				}

				return \%parsed_response;

			}
		}
		else {
			return 1;
		}
	}
	else {
		$self->error( $response->header('mess') );
		if ( $self->debug ) {
			if ( $path->query ) {
				warn "CURL for debugging: curl -X $method '$uri' -d '"
				  . $path->query . "'";
			}
			else {
				warn "CURL for debugging: curl -X $method '$uri'";
			}

		}
		return undef;
	}
}

sub has_error {
	return exists $_[0]->{_error};
}

sub error {
	my ( $self, $error ) = @_;
	if ($error) {
		if ( $self->debug ) {
			warn "FAILURE: $error";
		}
		$self->{_error} = $error;
	}
	else {
		return $self->{_error} || undef;
	}
}

1;

__END__

=head1 NAME

Net::MCMP - Mod Cluster Management Protocol client

=head1 SYNOPSIS

    use Net::MCMP;
    my $mcmp = Net::MCMP->new( { uri => 'http://127.0.0.1:6666' } );
    $mcmp->config(
        {
            JvmRoute => 'MyJVMRoute',
            Host     => 'localhost',
            Port     => '3000',
            Type     => 'http',
            Context  => '/myContext',
            Alias    => 'Vhost',
        }
    );

    $mcmp->enable_app(
        {
            JvmRoute => 'MyJVMRoute',
            Alias    => 'Vhost',
            Context  => '/myContext'
        }
    );

    $mcmp->remove_app(
        {
            JvmRoute => 'MyJVMRoute',
            Alias    => 'SomeHost',
            Context  => '/cluster'
        }
    );

    $mcmp->remove_route(
        {
            JvmRoute => 'MyJVMRoute',
        }
    );

    $mcmp->status(
        {
            JvmRoute => 'MyJVMRoute',
            Load     => 55,
        }
    );

    $mcmp->disable_app(
        {
            JvmRoute => 'MyJVMRoute',
            Alias    => 'SomeHost',
            Context  => '/cluster'
        }
    );

    $mcmp->stop_app(
        {
            JvmRoute => 'MyJVMRoute',
            Alias    => 'SomeHost',
            Context  => '/cluster'
        }
    );

=head1 DESCRIPTION

I<Net::MCMP> is an implementation of the Mod Cluster
Management Protocol (MCMP). I<Net::MCMP> uses I<LWP::UserAgent> and I<HTTP::Request> for its
communication with mod_cluster. 

MCMP stands for Mod Cluster Management Protocol and is a method of
adding proxy settings dynamically, as appose to
creating static apache rules. 

Official documentation of MCMP can be found here: https://community.jboss.org/wiki/Mod-ClusterManagementProtocol

=head1 USAGE

=head2 Net::MCMP->new(\%args)

Creates a new MCMP object, and returns a I<Net::MCMP> object 
representing that connection.

	my $mcmp = Net::MCMP({ uri => 'http://127.0.0.1:6666', debug => 0});

I<%args> can contain:

=over 4

=item * uri (required)

The URI of a mod_cluster handler.

=item * debug (optional)

If set to a true value, debugging messages will be printed out
for every request and respons to mod_cluster.

=back


=head2 $mcmp->config(\%conig)

Sends configuration for a node or set of nodes

If a low-level protocol error or unexpected local error occurs,
we die with an error message.

	$mcmp->config({
		JvmRoute            => "MyAppNode1",
		Balancer            => 'MyApp',
		Domain              => 'MyApp',
		StickySessionCookie => 'myapp_session',
		StickySessionPath   => 'myapp',
		Host                => '192.168.0.101',
		Port                => '3000',
		Type                => 'http',
		Context             => '/myapp',
		Alias               => "MyApp",	
	});

I<%config> can contain: 

=over 4

=item * JvmRoute (required)

Name of the node.

=item * Alias (required)

Virtual host entry in https for the node

=item * Host (optional)

IP address (or hostname) where the node is going to receive requests from httpd (Defaults to localhost)

=item * Port (optional)

Port on which the node except to receive requests (Defaults to 8009)

=item * Type (optional)

http/https/ajp The protocol to use between httpd and application to process requests (Defaults to ajp)

=item * Domain (optional)

domain corresponding to the node (ie LB group), (Defaults to mycluster)

=item * Balancer (optional)

is the name of the balancer in httpd (Defaults to mycluster)

=item * StickySession (optional)

stick a request to a node "yes"/"no" (Defaults to "yes")

=item * StickySessionCookie (optional)

Name of the cookie containing the sessionid (Defaults to "JSESSIONID")

=item * StickySessionPath (optional)

Name of the parameter containing the sessionid (Defaults to "jsessionid")

=item * StickySessionRemove (optional)

remove the sessionid (cookie or parameter) when the request can't be routed to the right node "yes"/"no" (Defaults to "no")

=item * StickySessionForce (optional)

Return an error if the request can't be routed according to JVMRoute (Defaults to "yes")

=item * WaitWorker (optional)

value in seconds: time to wait for an available worker. (Defaults to 0, no wait)

=item * MaxAttempts (optional)

number of attemps to send the request to the backend server (Defaults to 1)

=item * FlushPackets (optional)

Tell how to flush the packets. On: Send immediately, Auto wait for flushwait time before sending, Off don't flush. (Defaults to "off")

=item * FlushWait (optional)

Time to wait before flushing. Value in seconds (Defaults to 10)

=item * Ping (optional)

Time to wait for a pong answer to a ping. 0 means we don't try to ping before sending. Value in secondes (Defaults to 10)

=item * Smax (optional)

soft max inactive connection over that limit after ttl are closed. Default depends on the mpm configuration

=item * Ttl (optional)

max time in seconds to life for connection above smax. (Defaults to 60)

=item * Timeout (optional)

Max time httpd will wait for the backend connection. (Defaults to 0, no timeout)

=item * Context (optional)

List the context the virtual host list supports like /myapp,/ourapp.

=back

=head2 $mcmp->ping(\%ping)

Request a ping to httpd or node

	my $ping_resp = $mcmp->ping(
		{
			JvmRoute => 'MyAppNode1',
		}
	);
	
	# SAMPLE $ping_response
	#$VAR1 = {
	#    'id' => '-540134453',
	#    'JvmRoute' => 'MyJVMRoute',
	#    'State' => 'OK',
	#    'Type' => 'PING-RSP'
	#};
		
=head2 $mcmp->enable_app(\%enable_app)

Sends request to enable newly configured Node

	$mcmp->enable_app(
		{
			JvmRoute => 'MyAppNode1',
			Alias    => 'MyApp',
			Context  => '/myapp'
		}
	);
	
=head2 $mcmp->status(\%status)

Sends load metrics for configured node, number from 1-100

	my $status_response = $mcmp->status(
		{
			JvmRoute => 'MyAppNode1',
			Load    => 99,
		}
	);

	# SAMPLE $status_response
	# $VAR1 = {
	#     'State' => 'OK',
	#     'JvmRoute' => 'MyJVMRoute',
	#     'id' => '-297586570',
	#     'Type' => 'STATUS-RSP'
	# };
	
	
=head2 $mcmp->disable_app(\%disable_app)

Apache should not create new session for this webapp, but still continue serving existing session on this node

	$mcmp->disable_app(
		{
			JvmRoute => 'MyAppNode1',
			Alias    => 'MyApp',
			Context  => '/myapp'
		}
	);

=head2 $mcmp->stop_app(\%stop_app)

New requests for this webapp should not be sent to this node.

	$mcmp->stop_app(
		{
			JvmRoute => 'MyAppNode1',
			Alias    => 'MyApp',
			Context  => '/myapp'
		}
	);

=head2 $mcmp->remove_app(\%remove_app)

Remove registered context from registered node.

	$mcmp->remove_app(
		{
			JvmRoute => 'MyAppNode1',
			Alias    => 'MyApp',
			Context  => '/myapp'
		}
	);
	
=head2 $mcmp->enable_route(\%enable_route)

Sends request to enable all of the registered contexts in a selected node

	$mcmp->enable_route(
		{
			JvmRoute => 'MyAppNode1',
		}
	);

=head2 $mcmp->disable_route(\%disable_route)

Sends request to disable all of the registered contexts in a selected node

	$mcmp->disable_route(
		{
			JvmRoute => 'MyAppNode1',
		}
	);

=head2 $mcmp->stop_route(\%stop_route)

Sends request to stop all of the registered contexts in a selected node

	$mcmp->stop_route(
		{
			JvmRoute => 'MyAppNode1',
		}
	);
	
=head2 $mcmp->remove_route(\%remove_route)

Sends request to remove registered node

	$mcmp->remove_route(
		{
			JvmRoute => 'MyAppNode1',
		}
	);
	
=head2 $mcmp->debug()

Sends request to receive unparsed DEBUG content of mod_cluster

	my $debug_response = $mcmp->debug();

=head2 $mcmp->info()

Sends request to receive unparsed INFO content of mod_cluster

	my $info_response = $mcmp->info();

=head2 $mcmp->has_errors()

Checks if a remote call returned any errors

	my $has_errors = $mcmp->has_errors();

=head2 $mcmp->error()

Error string that was returned from mod_cluster handler.

	my $error_string = $mcmp->error();
		
=head1 SUPPORT

For samples/tutorials, take a look at provided tests in F<t/> in
the distribution directory.

Please report all bugs via github at
https://github.com/winfinit/Net-MCMP

=head1 AUTHOR

Roman Jurkov (winfinit) E<lt>winfinit@cpan.orgE<gt>

=head1 COPYRIGHT

Copyright (c) 2014 the Net::MCMP L</AUTHORS> as listed above.

=head1 LICENSE

This program is free software, you can redistribute it and/or modify it
under the same terms as Perl itself.

=cut
