package Net::OpenVPN::Manager;

use namespace::autoclean -except => '_plugins';
use Moose;

use AnyEvent::DBI;
use AnyEvent::IO;
use Net::OpenVPN::Manager::Handler;
use Net::OpenVPN::Manager::Plugin;
use Module::Pluggable require => 1, sub_name => '_plugins';
use MIME::Base64;
use Plack::Builder;
use Plack::Middleware::ApiKey;
use YAML::XS qw(LoadFile Load);

our $VERSION = '0.17';

with 'MooseX::ConfigFromFile';

extends 'Web::Simple::Application';

has 'address' => (
    is => 'ro',
    isa => 'Str',
    required => 1,
);

has 'port' => (
    is => 'ro',
    isa => 'Int',
    required => 1,
);

has 'netdev' => (
    is => 'ro',
    isa => 'Str',
    required => 1,
);

has 'dbconnect' => (
    is => 'ro',
    isa => 'Str',
    required => 1,
);

has 'dbuser' => (
    is => 'ro',
    isa => 'Str',
    default => "",
);

has 'dbpass' => (
    is => 'ro',
    isa => 'Str',
    default => "",
);

has 'dbattrs' => (
    is => 'ro',
    isa => 'HashRef',
    default => sub { {} },
);

has 'apikeys' => (
    is => 'ro',
    traits => ['Array'],
    isa => 'ArrayRef',
    default => sub { [] },
    handles => {
        'all_apikeys' => 'elements',
    },
);

has 'plugins_config' => (
    is => 'ro',
    traits => ['Hash'],
    isa => 'HashRef',
    required => 1,
    handles => {
        'get_plugin_config' => 'get',
        'has_plugin_config' => 'exists'
    },
);

has 'plugins' => (
    is => 'ro',
    traits => ['Array'],
    isa => 'ArrayRef',
    lazy => 1,
    builder => '_plugins_builder',
    handles => {
        'all_plugins' => 'elements',
    },
);

has 'handler' => (
    is => 'ro',
    lazy => 1,
    builder => '_handler_builder',
);

has 'dispatchers' => (
    is => 'ro',
    isa => 'ArrayRef',
    traits => ['Array'],
    default => sub { [] },
    handles => {
        add_dispatcher => 'push',
        all_dispatchers => 'elements',
    },
);

has 'dbh' => (
    is => 'ro',
    lazy => 1,
    builder => '_dbh_builder',
);

has 'reloader' => (
    is => 'ro',
    builder => '_reloader_builder',
);

has 'middleware_apikey' => (
    is => 'ro',
    lazy => 1,
    builder => '_middleware_apikey_builder',
);

has 'status_cv' => (
    is => 'rw',
    default => undef,
);

sub get_config_from_file {
    my ($class, $configfile) = @_;

    return LoadFile($configfile);
}

sub _res_to_cv {
    my $res = shift;
    my $type = blessed($res);

    if (defined $type && $type eq "AnyEvent::CondVar") {
        return $res;
    } else {
        my $cv = AE::cv;
        $cv->send($res);
        return $cv;
    }
}

sub _extract_plugin_name {
    my $package = shift;
    my $prefix = __PACKAGE__."::Plugin::";

    if ($package =~ /^$prefix(.*)/) {
        return $1;
    } else {
        return $package;
    }
}

sub _plugins_builder {
    my $self = shift;

    my @plugins =
        sort { $a->order <=> $b->order }
        map { $self->log("instanciate $_"); $_->new({ manager => $self, %{$self->get_plugin_config(_extract_plugin_name($_))} }) }
        grep { $self->has_plugin_config(_extract_plugin_name($_)) }
        $self->_plugins;

    return [@plugins];
}

sub _handler_builder {
    my $self = shift;

    Net::OpenVPN::Manager::Handler->new(
        address => $self->address,
        port => $self->port,
        on_info => sub {
            $self->log("Connected to openvpn management interface");
        },
        on_status => sub {
            my ($handler, $status) = @_;
            $self->status_cv->send($status);
            $self->status_cv(undef);
        },
        on_client_connect => sub {
            my ($handler, $client) = @_;
            $self->log("Authentication request", $client);
            $self->authenticate($client)->cb(sub {
                my $res = $_[0]->recv;

                if ($res != PLUG_OK) {
                    return $handler->client_deny($client, "Unable to authenticate");
                }

                $self->log("Starting postauth phase", $client);
                $self->_plugin_sequence("Connectable", "connect", PLUG_FATAL, 1, $client)->cb(sub {
                    my $res = $_[0]->recv;
                    if ($res < PLUG_FATAL) {
                        $handler->client_auth($client);
                    } else {
                        $handler->client_deny($client, "Unable to connect");
                    }
                });
            });
        },
        on_client_reauth => sub {
            my ($handler, $client) = @_;
            $self->log("Reauthentication request", $client);
            $self->authenticate($client)->cb(sub {
                my $res = $_[0]->recv;
                if ($res == PLUG_OK) {
                    $handler->client_auth_nt($client);
                } else {
                    $handler->client_deny($client, "Unable to re-authenticate");
                }
            });
        },
        on_client_challenge => sub {
            my ($handler, $client, $challenge) = @_;
            $self->log("Starting challenge phase", $client);
            $self->_plugin_sequence("Challengable", "challenge", PLUG_FATAL, 0, $client, decode_base64($challenge))->cb(sub {
                my $res = $_[0]->recv;
            });
        },
        on_client_address => sub {
            my ($handler, $client, $address, $prio) = @_;
            $self->log("Starting address phase ($address $prio)", $client);
            $self->_plugin_sequence("Addressable", "address", PLUG_FATAL, 1, $client, $address, $prio)->cb(sub {
                my $res = $_[0]->recv;
                if ($res < PLUG_FATAL) {
                } else {
                }
            });
        },
        on_client_established => sub {
            my ($handler, $client) = @_;
            $self->log("Starting established phase", $client);
            $self->_plugin_sequence("Establishable", "establish", PLUG_FATAL, 1, $client)->cb(sub {
                my $res = $_[0]->recv;
                if ($res < PLUG_FATAL) {
                } else {
                }
            });
            #$handler->client_pf($client);
        },
        on_client_disconnected => sub {
            my ($handler, $client) = @_;
            $self->log("Starting disconnected phase", $client);
            $self->_plugin_sequence("Disconnectable", "disconnect", PLUG_FATAL, 1, $client)->cb(sub {
                my $res = $_[0]->recv;
                if ($res < PLUG_FATAL) {
                } else {
                }
            });
        },
    );
}

sub _dbh_builder {
    my $self = shift;

    AnyEvent::DBI->new($self->dbconnect, $self->dbuser, $self->dbpass, %{$self->dbattrs});
}

sub _reloader_builder {
    my $self = shift;

    AE::signal HUP => sub {
        $self->log("reloading config from ".$self->configfile);
        aio_load $self->configfile, sub {
            my $config = Load($_[0]);
            my $newconfig = $config->{plugins_config};
            foreach my $plug ($self->all_plugins) {
                my $plugname = _extract_plugin_name(blessed($plug));
                my $plugconfig = $newconfig->{$plugname};
                next unless defined $plugconfig;
                foreach my $param (keys(%$plugconfig)) {
                    my $attr = $plug->meta->get_attribute($param);
                    next unless defined $attr;
                    $self->log("set $param for plugin $plugname");
                    $attr->set_value($plug, $plugconfig->{$param});
                }

            }

            $self->_plugin_sequence("Reloadable", "reload", PLUG_FATAL, 1)->cb(sub {
                my $res = $_[0]->recv;
                if ($res < PLUG_FATAL) {
                } else {
                }
            });
        };
    }
}

sub _middleware_apikey_builder {
    my $self = shift;

    return Plack::Middleware::ApiKey->new(apikeys => $self->apikeys);
}

sub _plugin_sequence {
    my ($self, $role, $fct, $max, $all, @args) = @_;
    my $result = PLUG_OK;
    my $cv = AE::cv;

    my @plugins = grep { $_->does(__PACKAGE__."::$role") } $self->all_plugins;

    # no plugin case
    if (scalar(@plugins) < 1) {
        $cv->send(PLUG_NOOP);
        return $cv;
    }

    # run plugins
    my ($run_plugin, $cb);

    $run_plugin = sub {
        my $plug = shift @plugins;
        my $res = $plug->$fct(@args);
        _res_to_cv($res)->cb($cb);
    };

    $cb = sub {
        my $res = $_[0]->recv;

        # pending case (reserved to authentication)
        if ($res && ref $res eq 'ARRAY') {
            if ($role eq "Authenticable" || $role eq "2Authenticable") {
                return $cv->send($res);
            } else {
                $self->log("only authenticable phase can return pending");
                return $cv->send(PLUG_ERROR);
            }
        }

        unless (defined $res) {
            $self->log("plugin does not return a valide code");
            $res = PLUG_ERROR;
        }

        $result = $res if $res > $result;
        if ($res >= $max || ($res == PLUG_OK && !$all)) {
            return $cv->send($res);
        }

        if (scalar(@plugins) < 1) {
            return $cv->send($result);
        }

        $run_plugin->();
    };

    $run_plugin->();

    return $cv;
}

sub authenticate {
    my ($self, $client) = @_;
    my $cv = AE::cv;
    my $authcv = AE::cv;

    $self->log("Starting authentication phase 1", $client);
    $self->_plugin_sequence("Authenticable", "authenticate", PLUG_FATAL, 0, $client)->cb(sub {
        my $res = $_[0]->recv;
        if ($res == PLUG_OK) {
            $self->log("Starting authentication phase 2", $client);
            $self->_plugin_sequence("2Authenticable", "authenticate_phase2", PLUG_FATAL, 0, $client)->cb(sub {
                my $res = $_[0]->recv;
                if ($res == PLUG_NOOP) {
                    # no plugin to handle second auth phase
                    $self->log("No phase 2 done for this user", $client);
                    $authcv->send(PLUG_OK);
                } else {
                    $authcv->send($res);
                }
            });
        } else {
            $authcv->send($res);
        }
    });

    # handle pending case
    $authcv->cb(sub {
        my $res = $_[0]->recv;

        if ($res && ref $res eq 'ARRAY') {
            $res->[0]->cb(sub {
                my $res = $_[0]->recv;
                $cv->send($res);
            });
            $self->handler->client_pending($client, $res->[1], $res->[2]);
        } else {
            $cv->send($res);
        }
    });

    return $cv;
}

sub start {
    my $self = shift;

    $self->log("Starting plugins initialisation");
    $self->_plugin_sequence("Startable", "start", PLUG_MAX, 1)->cb(sub {
        my $res = $_[0]->recv;
        $self->log("Plugins initialised");
        $self->handler->start;
    });
}

sub log {
    my ($self, $msg, $client) = @_;
    my $prefix="";

    if ($client) {
        my $user = $client->username;
        my $ip = $client->ip;
        my $port = $client->get_env('trusted_port') || $client->get_env('untrusted_port');
        $prefix = "$user/$ip:$port ";
    }

    print STDERR "[ovpn-manager] $prefix$msg\n";
}

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

    unless ($self->status_cv) {
        $self->status_cv(AE::cv);
        $self->handler->status;
    }

    return $self->status_cv;
}

sub dispatch_request {
    my ($self, $env) = @_;
    my @dispatchers;

    map { push(@dispatchers, $_->($env)) } $self->all_dispatchers;

    return @dispatchers;
}

__PACKAGE__->meta->make_immutable;

1;

__END__

=head1 NAME

Net::OpenVPN::Manager - Manage OpenVPN through his management interface

=head1 VERSION

Version 0.17

=head1 Attributes

=head2 address

The IP address of the openvpn managment interface (required).

=head2 port

The TCP port of the openvpn managment interface (required).

=head2 netdev

The name of the tun device created by openvpn.

=head2 dbconnect

The DBI connection string (required).

=head2 dbuser

The DBI database username (default to "").

=head2 dbpass

The DBI database password (default to "").

=head2 dbattrs

The DBI attributes object (default to {}).

=head2 apikeys

A list of keys used to access to the HTTP API (default to []).

=head2 plugins_config

The plugin configuration object (required).

=head1 Class Methods

=head2 start

Initialize plugin and start the management socket handler. This method is call
by the openvpn-manager script.

=cut
