package Mojo::SNMP;
use Mojo::Base 'Mojo::EventEmitter';
use Mojo::IOLoop;
use Mojo::SNMP::Dispatcher;
use Net::SNMP    ();
use Scalar::Util ();
use constant DEBUG => $ENV{MOJO_SNMP_DEBUG} ? 1 : 0;
use constant MAXREPETITIONS => 10;

our $VERSION = '0.13';

my $DISPATCHER;
my @EXCLUDE_METHOD_ARGS = qw(maxrepetitions);
my %EXCLUDE             = (
  v1  => [qw(username authkey authpassword authprotocol privkey privpassword privprotocol)],
  v2c => [qw(username authkey authpassword authprotocol privkey privpassword privprotocol)],
  v3  => [qw(community)],
);

my %SNMP_METHOD;
__PACKAGE__->add_custom_request_method(bulk_walk => \&_snmp_method_bulk_walk);
__PACKAGE__->add_custom_request_method(walk      => \&_snmp_method_walk);

$Net::SNMP::DISPATCHER = $Net::SNMP::DISPATCHER;    # avoid warning

has concurrent     => 20;
has defaults       => sub { +{} };
has master_timeout => 0;
has ioloop         => sub { Mojo::IOLoop->singleton };

# these attributes are experimental and therefore not exposed. Let me know if
# you use them...
has _dispatcher => sub { $DISPATCHER ||= Mojo::SNMP::Dispatcher->new(ioloop => shift->ioloop) };

sub add_custom_request_method {
  my ($class, $name, $cb) = @_;
  $SNMP_METHOD{$name} = $cb;
  $class;
}

sub prepare {
  my $cb    = ref $_[-1] eq 'CODE' ? pop : undef;       # internal usage. might change
  my $self  = shift;
  my $hosts = ref $_[0] eq 'ARRAY' ? shift : [shift];
  my $args  = ref $_[0] eq 'HASH' ? shift : {};
  my %args  = %$args;

  $hosts = [keys %{$self->{sessions} || {}}] if $hosts->[0] and $hosts->[0] eq '*';

  defined $args{$_} or $args{$_} = $self->defaults->{$_} for keys %{$self->defaults};
  $args{version} = $self->_normalize_version($args{version} || '');
  delete $args{$_} for @{$EXCLUDE{$args{version}}}, @EXCLUDE_METHOD_ARGS;
  delete $args{stash};

HOST:
  for my $key (@$hosts) {
    my ($host) = $key =~ /^([^|]+)/;
    local $args{hostname} = $host;
    my $key = $key eq $host ? $self->_calculate_pool_key(\%args) : $key;
    $self->{sessions}{$key} ||= $self->_new_session(\%args) or next HOST;

    local @_ = @_;
    while (@_) {
      my $method = shift;
      my $oid = ref $_[0] eq 'ARRAY' ? shift : [shift];
      push @{$self->{queue}{$key}}, [$key, $method, $oid, $args, $cb];
    }
  }

  $self->{n_requests} ||= 0;

  for ($self->{n_requests} .. $self->concurrent - 1) {
    my $queue = $self->_dequeue or last;
    $self->_prepare_request($queue);
  }

  $self->_setup if !$self->{_setup}++ and $self->ioloop->is_running;
  $self;
}

sub wait {
  my $self   = shift;
  my $ioloop = $self->ioloop;
  my $stop;

  $stop = sub {
    $_[0]->unsubscribe(finish  => $stop);
    $_[0]->unsubscribe(timeout => $stop);
    $ioloop->stop;
    undef $stop;
  };

  $self->_setup unless $self->{_setup}++;
  $self->once(finish  => $stop);
  $self->once(timeout => $stop);
  $ioloop->start;
  $self;
}

for my $method (qw(get get_bulk get_next set walk bulk_walk)) {
  eval <<"HERE" or die $@;
    sub $method {
      my (\$self, \$host) = (shift, shift);
      my \$args = ref \$_[0] eq 'HASH' ? shift : {};
      \$self->prepare(\$host, \$args, $method => \@_);
    }
    1;
HERE
}

sub _calculate_pool_key {
  join '|', map { defined $_[1]->{$_} ? $_[1]->{$_} : '' } qw(hostname version community username);
}

sub _dequeue {
  my $self = shift;
  my $key = (keys %{$self->{queue} || {}})[0] or return;
  return delete $self->{queue}{$key};
}

sub _finish {
  warn "[Mojo::SNMP] Finish\n" if DEBUG;
  $_[0]->emit('finish');
  $_[0]->{_setup} = 0;
}

sub _new_session {
  my ($self, $args) = @_;
  my ($session, $error) = Net::SNMP->new(%$args, nonblocking => 1);

  warn "[Mojo::SNMP] New session $args->{hostname}: ", ($error || 'OK'), "\n" if DEBUG;
  Mojo::IOLoop->next_tick(sub { $self->emit(error => "$args->{hostname}: $error") }) if $error;
  $session;
}

sub _normalize_version {
  $_[1] =~ /1/ ? 'v1' : $_[1] =~ /3/ ? 'v3' : 'v2c';
}

sub _prepare_request {
  my ($self, $queue) = @_;
  my $item = shift @$queue;

  unless ($item) {
    $queue = $self->_dequeue or return;
    $item = shift @$queue;
  }

  my ($key, $method, $list, $args, $cb) = @$item;
  my $session = $self->{sessions}{$key};
  my ($error, $success);

  # Dispatch to our mojo based dispatcher
  $Net::SNMP::DISPATCHER = $self->_dispatcher;

  unless ($session->transport) {
    warn "[Mojo::SNMP] <<< open connection\n" if DEBUG;
    unless ($session->open) {
      Mojo::IOLoop->next_tick(
        sub {
          return $self->$cb($session->error, undef) if $cb;
          return $self->emit(error => $session->error, $session, $args);
        },
      );
      return $self->{n_requests} || '0e0';
    }
  }

  warn "[Mojo::SNMP] <<< $method $key @$list\n" if DEBUG;
  Scalar::Util::weaken($self);
  $method = $SNMP_METHOD{$method} || "$method\_request";
  $success = $session->$method(
    $method =~ /bulk/ ? (maxrepetitions => $args->{maxrepetitions} || MAXREPETITIONS) : (),
    ref $method ? (%$args) : (),
    varbindlist => $list,
    callback    => sub {
      my $session = shift;

      eval {
        local @$args{qw(method request)} = @$item[1, 2];
        $self->{n_requests}--;
        if ($session->var_bind_list) {
          warn "[Mojo::SNMP] >>> success: $method $key @$list\n" if DEBUG;
          $cb ? $self->$cb('', $session) : $self->emit(response => $session, $args);
        }
        else {
          warn "[Mojo::SNMP] >>> error: $method $key @{[$session->error]}\n" if DEBUG;
          $cb ? $self->$cb($session->error, $session) : $self->emit(error => $session->error, $session, $args);
        }
        1;
      } or do {
        $self->emit(error => $@);
      };

      warn "[Mojo::SNMP] n_requests: $self->{n_requests}\n" if DEBUG;
      $self->_prepare_request($queue);
      warn "[Mojo::SNMP] n_requests: $self->{n_requests}\n" if DEBUG;
      $self->_finish unless $self->{n_requests};
    },
  );

  return ++$self->{n_requests} if $success;
  $self->emit(error => $session->error, $session);
  return $self->{n_requests} || '0e0';
}

sub _setup {
  my $self = shift;
  my $timeout = $self->master_timeout or return;
  my $tid;

  warn "[Mojo::SNMP] Timeout: $timeout\n" if DEBUG;
  Scalar::Util::weaken($self);

  $tid = $self->ioloop->timer(
    $timeout => sub {
      warn "[Mojo::SNMP] Timeout\n" if DEBUG;
      $self->ioloop->remove($tid);
      $self->emit('timeout');
      $self->{_setup} = 0;
    }
  );
}

sub _snmp_method_bulk_walk {
  my ($session, %args) = @_;
  my $base_oid       = $args{varbindlist}[0];
  my $last           = $args{callback};
  my $maxrepetitions = $args{maxrepetitions} || MAXREPETITIONS;
  my ($callback, $end, %tree, %types);

  $end = sub {
    if (scalar keys %tree) {
      $session->pdu->var_bind_list(\%tree, \%types);
    }
    else {
      $session->pdu->var_bind_list({$base_oid => 'noSuchObject'}, {$base_oid => Net::SNMP::NOSUCHOBJECT});
    }
    $session->$last;
    $end = $callback = undef;
  };

  $callback = sub {
    my ($session) = @_;
    my $res     = $session->var_bind_list    or return $end->();
    my @sortres = $session->var_bind_names() or return $end->();
    my $types   = $session->var_bind_types;
    my $next    = $sortres[-1];

    for my $oid (@sortres) {
      return $end->() if $types{$oid} or !Net::SNMP::oid_base_match($base_oid, $oid);
      $types{$oid} = $types->{$oid};
      $tree{$oid}  = $res->{$oid};
    }

    return $end->() unless $next;
    return $session->get_bulk_request(maxrepetitions => $maxrepetitions, varbindlist => [$next], callback => $callback);
  };

  $session->get_bulk_request(maxrepetitions => $maxrepetitions, varbindlist => [$base_oid], callback => $callback);
}

sub _snmp_method_walk {
  my ($session, %args) = @_;
  my $base_oid = $args{varbindlist}[0];
  my $last     = $args{callback};
  my ($callback, $end, %tree, %types);

  $end = sub {
    $session->pdu->var_bind_list(\%tree, \%types) if %tree;
    $session->$last;
    $end = $callback = undef;
  };

  $callback = sub {
    my ($session) = @_;
    my $res = $session->var_bind_list or return $end->();
    my $types = $session->var_bind_types;
    my @next;

    for my $oid (keys %$res) {
      if (!$types{$oid} and Net::SNMP::oid_base_match($base_oid, $oid)) {
        $types{$oid} = $types->{$oid};
        $tree{$oid}  = $res->{$oid};
        push @next, $oid;
      }
    }

    return $end->() unless @next;
    return $session->get_next_request(varbindlist => \@next, callback => $callback);
  };

  $session->get_next_request(varbindlist => [$base_oid], callback => $callback);
}

1;

=encoding utf8

=head1 NAME

Mojo::SNMP - Run SNMP requests with Mojo::IOLoop

=head1 VERSION

0.13

=head1 SYNOPSIS

=head2 Using methods with callbacks

  use Mojo::SNMP;
  my $snmp = Mojo::SNMP->new;

  $snmp->defaults({timeout => 3, community => "public"});

  $snmp->get_next(["10.0.0.1", "10.0.0.2"], ["1.3.6.1.2.1.1.3.0"], sub {
    my ($self, $err, $session) = @_;
  });

  $snmp->wait unless $snmp->ioloop->is_running;

=head2 Using prepare

  use Mojo::SNMP;
  my $snmp = Mojo::SNMP->new;
  my @response;

  $snmp->on(response => sub {
    my ($snmp, $session, $args) = @_;
    warn "Got response from $args->{hostname} on $args->{method}(@{$args->{request}})...\n";
    push @response, $session->var_bind_list;
  });

  $snmp->defaults({
    community => "public", # v1, v2c
    username  => "foo", # v3
    version   => "v2c", # v1, v2c or v3
  });

  $snmp->prepare("127.0.0.1", get_next => ["1.3.6.1.2.1.1.3.0"]);
  $snmp->prepare("localhost", {version => "v3"}, get => ["1.3.6.1.2.1.1.3.0"]);

  # start the IOLoop unless it is already running
  $snmp->wait unless $snmp->ioloop->is_running;

=head1 DESCRIPTION

L<Mojo::SNMP> is an async library for fetching or writing data from/to many
SNMP agents. The module does its best to not get in your way, but rather
provide a simple API which allow you to extract information from multiple
servers at the same time.

This module uses L<Net::SNMP> and L<Mojo::IOLoop> to fetch data from hosts
asynchronously. It does this by using a custom dispatcher,
L<Mojo::SNMP::Dispatcher>, which attach the sockets created by L<Net::SNMP>
directly into the ioloop reactor.

If you want greater speed, you should check out L<Net::SNMP::XS> and make sure
L<Mojo::Reactor::EV> and L<EV> is installed.

L<Mojo::SNMP> is supposed to be a replacement for a module I wrote earlier,
called L<SNMP::Effective>. Reason for the rewrite is that I'm using the
framework L<Mojolicious> which includes an awesome IO loop which allow me to
do cool stuff inside my web server.

=head1 EVENTS

=head2 error

  $self->on(error => sub { my ($self, $str, $session, $args) = @_; ... });

Emitted on errors which may occur. C<$session> is a L<Net::SNMP> object and is
only available if the error is a result of a L<Net::SNMP> method, such as
L<get_request()|Net::SNMP/get_request>.

See L</response> for C<$args> description.

=head2 finish

  $self->on(finish => sub { my $self = shift; ... });

Emitted when all requests have completed.

=head2 response

  $self->on(response => sub { my ($self, $session, $args) = @_; ... });

Called each time a host responds. The C<$session> is the current L<Net::SNMP>
object. C<$args> is a hash ref with the arguments given to L</prepare>, with
some additional information:

  {
    method => $str, # get, get_next, ...
    request => [$oid, ...],
    ...
  }

=head2 timeout

  $self->on(timeout => sub { my $self = shift; ... })

Emitted if L</wait> has been running for more than L</master_timeout> seconds.

=head1 ATTRIBUTES

=head2 concurrent

  $self = $self->concurrent(20);
  $int = $self->concurrent;

How many hosts to fetch data from at once. Default is 20. (The default may
change in later versions)

=head2 defaults

  $self = $self->defaults({community => "public"});
  $hash_ref = $self->community;

This attribute holds a hash ref with default arguments which will be passed
on to L<Net::SNMP/session>. User-submitted C<%args> will be merged with the
defaults before being submitted to L</prepare>. C<prepare()> will filter out
and ignore arguments that don't work for the SNMP C<version>.

NOTE: SNMP version will default to "v2c".

=head2 master_timeout

  $self = $self->master_timeout(15);
  $int = $self->master_timeout;

How long to run in total before timeout. Note: This is NOT per host but for
the complete run. Default is 0, which means that it will never time out.

If you want to set a timeout per request request to a host, then this need
to be set in L</defaults> or in C<$args> passed on to L</prepare> or one of
the other request methods.

=head2 ioloop

  $self = $self->ioloop(Mojo::IOLoop->new);
  $ioloop = $self->ioloop;

Holds an instance of L<Mojo::IOLoop>.

=head1 METHODS

=head2 add_custom_request_method

  Mojo::SNMP->add_custom_request_method(my_custom_method => sub {
    my ($session, %args) = @_;
    # do custom stuff..
  });

L<Net::SNMP> has defined basic methods to write/retrieve data from/to the SNMP
agent. L</add_custom_request_method> allow you to add support for custom
methods, which can be useful if you find yourself doing the same complicated
logic over and over again. L</bulk_walk> and L</walk> are custom methods
bundled with this module.

NOTE: This method will define the methods in a global scope, meaning the code
below will call the custom callback instead of L<Net::SNMP/get_next_request>
for all instances of L<Mojo::SNMP>:

  $self->add_custom_request_method(get_next => sub { ... });

=head2 bulk_walk

  $self->bulk_walk($host, $args, \@oids, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

This is a custom SNMP method added by L</add_custom_request_method>. See
L</prepare> for generic information about the variables associated with this
method.

This method will run L<Net::SNMP/get_bulk_request> until it receives an OID
which does not match the base OID. C<maxrepetitions> in C<$args> will default
to 10, but could be overrided to potentially increase performance. Example:

  $self->bulk_walk("192.168.0.1" => {maxrepetitions => 25}, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

=head2 get

  $self->get($host, \%args, \@oids, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

Will send a SNMP get-request to the remote agent. See L<Net::SNMP/get_request>
for details on which C<%args> you can pass on. See L</prepare> for generic
information about the variables associated with this method.

=head2 get_bulk

  $self->get_bulk($host, \%args, \@oids, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

Will send a SNMP get-bulk-request to the remote agent. See
L<Net::SNMP/get_bulk_request> for details on which C<%args> you can pass on.
See L</prepare> for generic information about the variables associated with
this method.

=head2 get_next

  $self->get_next($host, \%args, \@oids, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

Will send a SNMP get-next-request to the remote agent. See
L<Net::SNMP/get_next_request> for details on which C<$args> you can pass on.
See L</prepare> for generic information about the variables associated with
this method.

=head2 prepare

  $self = $self->prepare($host, \%args, ...);
  $self = $self->prepare(\@hosts, \%args, ...);
  $self = $self->prepare(\@hosts, ...);
  $self = $self->prepare("*" => ...);

=over 4

=item * $host

This can either be an array ref or a single host. The "host" can be whatever
L<Net::SNMP/session> can handle; generally a hostname or IP address.

=item * \%args

A hash ref of options which will be passed directly to L<Net::SNMP/session>.
This argument is optional. See also L</defaults>.

=item * dot-dot-dot

A list of key-value pairs of SNMP operations and bindlists which will be given
to L</prepare>. The operations are the same as the method names available in
L<Net::SNMP>, but without "_request" at end:

  get
  get_next
  set
  get_bulk
  inform
  walk
  bulk_walk
  ...

The special hostname "*" will apply the given operation to all previously
defined hosts.

=back

Examples:

  $self->prepare("192.168.0.1" => {version => "v2c"}, get_next => [$oid, ...]);
  $self->prepare("192.168.0.1" => {version => "v3"}, get => [$oid, ...]);
  $self->prepare(localhost => set => [$oid => OCTET_STRING, $value, ...]);
  $self->prepare("*" => get => [$oid, ...]);

Note: To get the C<OCTET_STRING> constant and friends you need to do:

  use Net::SNMP ":asn1";

=head2 set

  use Net::SNMP ":asn1"; # Export OCTET_STRING

  $self->set($host, $args => [$oid, OCTET_STRING, $value, ...], sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

Will send a SNMP set-request to the remote agent. See L<Net::SNMP/set_request>
for details on which C<$args> you can pass on. See L</prepare> for generic
information about the variables associated with this method.

=head2 walk

  $self->walk($host, $args, \@oids, sub {
    my ($self, $err, $session) = @_;
    return warn $err if $err;
    push @{$res{$host}}, $session->var_bind_list;
  });

This is a custom SNMP method added by L</add_custom_request_method>. See
L</prepare> for generic information about the variables associated with this
method.

This method will run L<Net::SNMP/get_next_request> until an oid retrieved does
not match the base OID, or if the tree is exhausted. You might want to use
L</bulk_walk> instead for better performance.

=head2 wait

  $self->wait;

This is useful if you want to block your code: C<wait()> starts the ioloop and
runs until L</master_timeout> or L</finish> is reached.

  my $snmp = Mojo::SNMP->new;
  $snmp->prepare(...)->wait; # blocks while retrieving data
  # ... your program continues after the SNMP operations have finished.

=head1 AUTHOR

Jan Henning Thorsen - C<jhthorsen@cpan.org>

=head1 CONTRIBUTORS

Espen Tallaksen - C<espen.tallaksen@telenor.com>

Joshua Keroes - C<joshua.keroes@integratelecom.com>

Oliver Gorwits - C<oliver@cpan.org>

Per Carlson - C<per.carlson@broadnet.no>

=head1 COPYRIGHT & LICENSE

Copyright (C) 2012-2018, L</AUTHOR> and L</CONTRIBUTORS>.

This library is free software. You can redistribute it and/or modify
it under the same terms as Perl itself.

=cut
