package Lemonldap::NG::Portal::Auth::Combination;

use strict;
use Mouse;
use Lemonldap::NG::Common::Combination::Parser;
use Lemonldap::NG::Portal::Main::Constants qw(
  PE_CONFIRM
  PE_ERROR
  PE_FIRSTACCESS
  PE_FORMEMPTY
  PE_PASSWORD_OK
  PE_OK
);
use Scalar::Util 'weaken';

our $VERSION = '2.21.0';

extends 'Lemonldap::NG::Portal::Main::Auth';
with 'Lemonldap::NG::Portal::Lib::OverConf';

# PROPERTIES

has stackSub => ( is => 'rw' );

has wrapUserLogger => (
    is      => 'rw',
    lazy    => 1,
    default => sub {
        Lemonldap::NG::Portal::Lib::Combination::UserLogger->new(
            $_[0]->userLogger );
    }
);

# INITIALIZATION

sub init {
    my $self = shift;

    # Check if expression exists
    unless ( $self->conf->{combination} ) {
        $self->error('No combination found');
        return 0;
    }

    # Load all declared modules
    my %mods;
    foreach my $key ( keys %{ $self->conf->{combModules} } ) {
        my @tmp = ( undef, undef );
        my $mod = $self->conf->{combModules}->{$key};

        unless ( $mod->{type} and defined $mod->{for} ) {
            $self->error("Malformed combination module $key");
            return 0;
        }

        # Override parameters

        # "for" key can have 3 values:
        # 0: this module will be used for Auth and UserDB
        # 1: this module will be used for Auth only
        # 2: this module will be used for UserDB only

        # Load Auth module
        if ( $mod->{for} < 2 ) {
            $tmp[0] = $self->loadPlugin( "::Auth::$mod->{type}", $mod->{over} );
            unless ( $tmp[0] ) {
                $self->error("Unable to load Auth::$mod->{type}");
                return 0;
            }
            $tmp[0]->{userLogger} = $self->wrapUserLogger;
            weaken $tmp[0]->{userLogger};
        }

        # Load UserDB module
        unless ( $mod->{for} == 1 ) {
            $tmp[1] =
              $self->loadPlugin( "::UserDB::$mod->{type}", $mod->{over} );
            unless ( $tmp[1] ) {
                $self->error("Unable to load UserDB::$mod->{type}");
                return 0;
            }
            $tmp[1]->{userLogger} = $self->wrapUserLogger;
            weaken $tmp[1]->{userLogger};
        }

        # Store modules as array
        $mods{$key} = \@tmp;
    }

    # Compile expression
    eval {
        $self->stackSub(
            Lemonldap::NG::Common::Combination::Parser->parse(
                \%mods, $self->conf->{combination}
            )
        );
    };
    if ($@) {
        $self->error("Bad combination: $@");
        return 0;
    }
    return 1;
}

# Each first method must call getStack() to get the auth scheme available for
# the current user

## Auth steps
#############
sub extractFormInfo {
    my $self = shift;
    return $self->try( 0, 'extractFormInfo', @_ );
}

sub authenticate {
    my $self = shift;
    return $self->try( 0, 'authenticate', @_ );
}

sub setAuthSessionInfo {
    my $self = shift;
    return $self->try( 0, 'setAuthSessionInfo', @_ );
}

sub getDisplayType {
    my $self = shift;
    my ($req) = @_;
    return $self->conf->{combinationForms}
      if ( $self->conf->{combinationForms} );

    my ( $nb, $stack ) = (
        $req->data->{dataKeep}->{combinationTry},
        $req->data->{combinationStack}
    );
    my $res = $stack->[$nb]->[0]->( 'getDisplayType', @_ );
    return $res;
}

sub authLogout {
    my $self = shift;
    my ($req) = @_;
    $self->getStack( $req, 'extractFormInfo' ) or return PE_ERROR;

    # Avoid warning msg at first access
    $req->userData->{_combinationTry} ||= 0;

    # Avoid out of bounds access
    if ( $req->userData->{_combinationTry} <
        @{ $req->data->{combinationStack} // [] } )
    {
        my $sub =
          $req->data->{combinationStack}->[ $req->userData->{_combinationTry} ]
          ->[0];
        if ($sub) {
            my ( $res, $name ) = $sub->( 'authLogout', @_ );
            $self->logger->debug(qq'User disconnected using scheme "$name"');
            return $res;
        }
    }
    $self->logger->warn( "Condition changed between login and logout for "
          . $req->user
          . ", unable to select good backend" );
    return PE_OK;
}

sub authFinish {
    return PE_OK;
}

sub authForce {
    return 0;
}

sub setSecurity {
    my $self = shift;
    my ($req) = @_;
    $self->getStack( $req, 'extractFormInfo' ) or return;
    eval {
        $req->data->{combinationStack}
          ->[ $req->data->{dataKeep}->{combinationTry} ]->[0]
          ->( 'setSecurity', @_ );
    };
    $self->logger->debug($@) if ($@);
}

## UserDB steps
###############
# Note that UserDB::Combination uses the same object.
sub getUser {
    my $self = shift;
    return $self->try( 1, 'getUser', @_ );
}

sub findUser {
    my $self = shift;
    return $self->try( 1, 'findUser', @_ );
}

sub setSessionInfo {
    my $self = shift;
    return $self->try( 1, 'setSessionInfo', @_ );
}

sub setGroups {
    my $self = shift;
    return $self->try( 1, 'setGroups', @_ );
}

sub getStack {
    my ( $self, $req, @steps ) = @_;
    return $req->data->{combinationStack}
      if ( $req->data->{combinationStack} );
    my $stack = $req->data->{combinationStack} = $self->stackSub->( $req->env );
    unless ($stack) {
        $self->logger->error('No authentication scheme for this user');
    }
    @{ $req->data->{combinationSteps} } = ( @steps, @{ $req->steps } );
    $req->data->{dataKeep}->{combinationTry} ||= 0;
    return $stack;
}

# Main running method: launch the next scheme if the current fails
sub try {
    my ( $self, $type, $subname, $req, @args ) = @_;

    # Get available authentication schemes for this user if not done
    unless ( defined $req->data->{combinationStack} ) {
        $self->getStack( $req, $subname ) or return PE_ERROR;
    }
    my ( $nb, $stack ) = (
        $req->data->{dataKeep}->{combinationTry},
        $req->data->{combinationStack}
    );

    # If more than 1 scheme is available
    my ( $res, $name );
    unless ( ref $stack->[$nb]->[$type] ) {
        $self->logger->error(
'Something went wrong in combination, unable to find any auth scheme (try == '
              . ( $nb + 1 )
              . ')' );
        return PE_ERROR;
    }

    my $stop = 0;
    if ( $nb < @$stack - 1 ) {

        ( $res, $name ) = $stack->[$nb]->[$type]->( $subname, $req, @args );

        # On error, restart authentication with next scheme
        unless ( $stop = $self->stop( $stack->[$nb]->[$type], $res ) ) {
            $self->logger->info(qq'Scheme "$name" returned $res, trying next');
            $req->data->{dataKeep}->{combinationTry}++;
            $req->steps( [ @{ $req->data->{combinationSteps} } ] );
            $req->continue(1);
            return PE_OK;
        }
    }
    else {
        ( $res, $name ) = $stack->[$nb]->[$type]->( $subname, $req, @args );
    }
    $req->sessionInfo->{ [ '_auth', '_userDB' ]->[$type] } = $name;
    $req->sessionInfo->{_combinationTry} =
      $req->data->{dataKeep}->{combinationTry};
    if ( $res > 0 ) {
        if ($stop) {
            $self->userLogger->info(
                "Combination stopped by plugin $name (code $res)");
        }
        elsif ( $res != PE_FIRSTACCESS ) {
            $self->userLogger->warn( 'All schemes failed'
                  . ( $req->user ? ' for user ' . $req->user : '' ) . ' ('
                  . $req->address
                  . ')' );
        }
    }
    return $res;
}

# try() stores real Auth/UserDB module in sessionInfo
# This method reads them. It is called by getModule()
# (see Main::Run)
sub name {
    my ( $self, $req, $type ) = @_;
    return $req->sessionInfo->{ ( $type eq 'auth' ? '_auth' : '_userDB' ) }
      || 'Combination';
}

sub stop {
    my ( $self, $mod, $res ) = @_;
    return 1
      if (
        $res <= 0    # PE_OK
        or $res == PE_CONFIRM
        or $res == PE_PASSWORD_OK

        # TODO: adding this may generate behavior change
        #or $res == PE_FIRSTACCESS
        #or $res == PE_FORMEMPTY
      );
    my ( $ret, $name );
    $ret = $mod->( 'can', 'stop' );
    if ($ret) {
        eval { ( $ret, $name ) = $mod->( 'stop', $res ) };
        if ($@) {

            $self->logger->error(
                "Optional ${name}::stop() method failed: " . $@ );
            return 0;
        }
    }
    return $ret;
}

package Lemonldap::NG::Portal::Lib::Combination::UserLogger;

# This logger rewrite "warn" to "notice"
sub new {
    my ( $class, $realLogger ) = @_;
    return bless { logger => $realLogger }, $class;
}

sub warn {
    my ($auth) = caller(0);
    $_[0]->{logger}->notice("Combination ($auth): $_[1]");
}

sub AUTOLOAD {
    no strict;
    return $_[0]->{logger}->$AUTOLOAD( $_[1] )
      if ( $AUTOLOAD =~ /^(?:notice|debug|error|info)$/ );
}

1;
