package AccountManager::App;

use strict;
use warnings;

use CGI::Simple;
use CGI::Simple::Cookie;
use DateTime;
use English qw(-no_match_vars);
use Log::Any::Adapter;
use List::MoreUtils qw(any uniq);
use Template;
use Template::Constants qw(:chomp);
use UNIVERSAL::require;

use AccountManager::Account;
use AccountManager::Metadata;
use AccountManager::Entity;
use AccountManager::Token;
use AccountManager::Tools;
use AccountManager::L10N;
use AccountManager::Template::Plugin::Quote;

# Format de type URL HTTP ou URN
my %patterns = (
    entityid => qr{
        ^
        (?:
            https?://[\w.:/-]+
        |
            urn:[\w.:-]+
        )
        $
    }x
);

my %actions = (
    home               => 'req_home',
    status             => 'req_status',
    select_sp          => 'req_select_sp',
    select_email       => 'req_select_email',
    complete_challenge => 'req_complete_challenge',
    create_accounts    => 'req_create_accounts',
    download_accounts  => 'req_download_accounts',
);

my $version = '1.2.0';

sub new {
    my ($pkg, %args) = @_;

    my $self = {
        configuration => $args{configuration},
    };
    bless $self, $pkg;

    if ($self->{configuration}->{logger}) {
        Log::Any::Adapter->set(
            'File',
            $self->{configuration}->{logger}->{file},
            log_level => $self->{configuration}->{logger}->{level}
        );
    } else {
        warn "no logger in configuration, logging disabled\n";
    }

    $self->{logger} = Log::Any->get_logger();
    $self->{cgi}    = CGI::Simple->new();


    my $lang;
    my $cookies = CGI::Simple::Cookie->fetch();
    if ($self->{cgi}->param('lang')) {
        $lang = $self->{cgi}->param('lang');
        $self->{lh} = AccountManager::L10N->get_handle($lang);
        $self->{logger}->debugf("setting language from parameter: %s", $lang);
    } elsif ($cookies->{lang}) {
        $lang = $cookies->{lang}->value();
        $self->{lh} = AccountManager::L10N->get_handle($lang);
        $self->{logger}->debugf("setting language from cookie: %s", $lang);
    } elsif ($lang = $ENV{HTTP_ACCEPT_LANGUAGE}) {
        $lang = I18N::LangTags::Detect::detect();
        if ($lang =~ /^(\w\w)-(\w\w)$/) {
            $lang = $1;
        }
        $self->{lh} = AccountManager::L10N->get_handle($lang);
        $self->{logger}->debugf("setting language from HTTP_ACCEPT_LANGUAGE header: %s", $lang);
    } else {
        $self->{lh} = AccountManager::L10N->get_handle('en');
        $self->{logger}->debugf("using default language");
    }
    $self->{lh}->load_custom_lexicon($args{custom_l10n});

    if (!$self->{configuration}->{mailer}) {
        $self->{logger}->fatal(
            "No mailer defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }

    if (!$self->{configuration}->{idp}) {
        $self->{logger}->fatal(
            "No IDP defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }

    if (!$self->{configuration}->{database}) {
        $self->{logger}->fatal(
            "No database defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors => [ 'internal' ]
            }
        );
    } else {
        AccountManager::DB->register_db(
            driver   => $self->{configuration}->{database}->{type},
            database => $self->{configuration}->{database}->{name},
            host     => $self->{configuration}->{database}->{host},
            username => $self->{configuration}->{database}->{username},
            password => $self->{configuration}->{database}->{password},
            options  => $self->{configuration}->{database}->{options} ?
                [ split(/, */, $self->{configuration}->{database}->{options}) ] : undef,
        );
    }

    $self->{db} = AccountManager::DB->new();

    return $self;
}

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

    if ($self->{logger}->is_debug()) {
        Data::Dump->require();
        my %parameters = $self->{cgi}->Vars();
        $self->{logger}->debugf("input parameters: %s", Data::Dump::dump(\%parameters));
    }

    # process requested action
    my $action = $self->{cgi}->param('action') || 'home';
    if ($actions{$action}) {
        $self->{logger}->debug("Processing action '$action'");
        my $method = $actions{$action};
        $self->$method();
    } else {
        $self->abort(
            logs => "Unknown action '$action'",
            user => "Unknown action '$action'"
        );
    }

    return 1;
}

## Return HTML content
sub respond {
    my ($self, %in) = @_;

    $in{data}->{app} = {
        url           => $ENV{SCRIPT_NAME},
        login_url     => $self->{configuration}->{app}->{login_url},
        name          => $self->{configuration}->{app}->{name},
        support_email => $self->{configuration}->{app}->{support_email},
        version       => $version,
    };
    $in{data}->{lh} = $self->{lh};

    my $templates_dir   = $self->{configuration}->{setup}->{templates_dir};
    my $templates_theme = $self->{configuration}->{setup}->{templates_theme} || 'default';

    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => [
            sprintf("%s/web/%s", $templates_dir, $templates_theme),
            sprintf("%s/web", $templates_dir),
            sprintf("%s/accounts", $templates_dir)
        ]
    });

    $self->{logger}->debug("Responding with template '$in{template}'");

    binmode(STDOUT, ":encoding(UTF-8)");

    my $cookie = CGI::Simple::Cookie->new(
        -name    => 'lang',
        -value   => $self->{lh}->language_tag(),
        -expires => undef,
    );

    print $self->{cgi}->header(
        -type    => 'text/html',
        -charset => 'utf8',
        -cookie  => [ $cookie ]
    );

    unless ($tt2->process($in{template}, $in{data}, \*STDOUT)) {
        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
    }

    exit 0;
}

sub abort {
    my $self = shift;
    my %args = @_;

    $self->{logger}->error($args{log}) if $args{log};

    $self->respond(
        template => 'errors.tt2.html',
        data => {
            errors => [ $args{user} ]
        }
    );
}


sub req_select_sp {
    my ($self, %args) = @_;

    $self->check_authentication(action => 'select_sp')
        if $self->{configuration}->{app}->{login_url};

    my $sps = AccountManager::Entity->get_entities(
        db => $self->{db},
        query => [
            type => 'sp',
        ],
        sort_by => 'display_name'
    );

    my $idp;
    if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) {
        my $idps = AccountManager::Entity->get_entities(
            db    => $self->{db},
            query => [
                type     => 'idp',
                entityid => $ENV{HTTP_SHIB_IDENTITY_PROVIDER},
            ]
        );
        $idp = $idps->[0];
    }

    $self->respond(
        template => 'select_sp.tt2.html',
        data     => {
            action => 'select_sp',
            sps    => $sps,
            idp    => $idp,
        }
    );
}

sub req_select_email {
    my ($self, %args) = @_;

    $self->check_authentication(action => 'select_email')
        if $self->{configuration}->{app}->{login_url};

    my $entityid = $self->get_parameter(name => 'entityid');

    my $sp = AccountManager::Entity->new(
        db       => $self->{db},
        entityid => $entityid
    );
    $self->abort(
        log  => sprintf("No such SP '%s' in database", $entityid),
        user => "no_such_entity"
    ) if !$sp->load(speculative => 1);

    # override metadata contacts if needed
    my $contacts =
        $self->{configuration}->{$entityid}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $sp->contacts($sp->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
            $sp->contacts(split(/, */, $contacts));
        }
    }

    $self->respond(
        template => 'select_email.tt2.html',
        data     => {
            action   => 'select_email',
            sp       => $sp,
            entityid => $entityid,
        }
    );
}

sub req_complete_challenge {
    my ($self, %args) = @_;

    $self->check_authentication(action => 'complete_challenge')
        if $self->{configuration}->{app}->{login_url};

    my $entityid   = $self->get_parameter(name => 'entityid');
    my $email      = $self->get_parameter(name => 'email');

    my $sp = AccountManager::Entity->new(
        db       => $self->{db},
        entityid => $entityid,
    );
    $self->abort(
        log  => sprintf("No such SP '%s' in database", $entityid),
        user => "no_such_entity"
    ) if !$sp->load(speculative => 1);

    # override metadata contacts if needed
    my $contacts =
        $self->{configuration}->{$entityid}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $sp->contacts($sp->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
            $sp->contacts(split(/, */, $contacts));
        }
    }

    ## Check that email is a known contact for this SP
    $self->abort(
        log  => "Requested a token for SP $entityid with unautorized address $email",
        user => "internal",
    ) if !$sp->is_contact($email);

    # delete any previous token for the same email/service couple
    my $old_token = AccountManager::Token->new(
        db            => $self->{db},
        email_address => $email,
        entityid      => $entityid,
    );

    if ($old_token->load(speculative => 1)) {
        $self->abort(
            log  => "Failed to delete old authentication token",
            user => "internal"
        ) if !$old_token->delete();
    }

    # compute a new token
    my $validity_period =
        $self->{configuration}->{service}->{tokens_validity_period};
    my $token = AccountManager::Token->new(
        db              => $self->{db},
        email_address   => $email,
        entityid        => $entityid,
        creation_date   => DateTime->now(),
        expiration_date => DateTime->now()->add(hours => $validity_period),
        secret          => AccountManager::Tools::generate_secret(20)
    );

    $self->abort(
        log  => "Failed to save creation authentication token",
        user => "internal"
    ) if !$token->save();

    my $theme_templates_dir = sprintf(
        "%s/mail/%s",
        $self->{configuration}->{setup}->{templates_dir},
        $self->{configuration}->{setup}->{templates_theme} || 'default'
    );
    my $default_templates_dir = sprintf(
        "%s/mail",
        $self->{configuration}->{setup}->{templates_dir},
    );
    my $templates_dir = -d $theme_templates_dir ?
        $theme_templates_dir  :
        $default_templates_dir;

    # build content
    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => $templates_dir
    });
    my $source_ip = get_source_address();
    my $user =
        $ENV{'HTTP_DISPLAYNAME'} ? $ENV{'HTTP_DISPLAYNAME'} :
        $ENV{'displayName'}      ? $ENV{'displayName'}      :
        undef;
    my $idp =
        $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} ? $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} :
        $ENV{'Shib-Identity-Provider'}      ? $ENV{'Shib-Identity-Provider'}      :
        undef;
    my $data = {
        app => {
            url           => $self->{configuration}->{app}->{url},
            support_email => $self->{configuration}->{app}->{support_email},
            version       => $self->{configuration}->{app}->{version},
            name          => $self->{configuration}->{app}->{name},
        },
        user      => $user,
        source_ip => $source_ip,
        idp       => {
            entityid => $idp,
        },
        sp        => {
            entityid => $entityid,
        },
        to        => $email,
        token     => $token->secret(),
        challenge_url => sprintf(
            '%s?action=complete_challenge&entityid=%s&email=%s',
            $self->{configuration}->{app}->{url},
            $entityid,
            $email,
        ),
        lh        => $self->{lh},
    };
    my $text_content;
    my $html_content;
    $tt2->process('send_authentication_token.tt2.txt',  $data, \$text_content);
    $tt2->process('send_authentication_token.tt2.html', $data, \$html_content);

    # wrap in message
    Email::MIME->require();
    Email::Sender::Simple->require();

    my $message = Email::MIME->create(
        header_str => [
            'From'         => sprintf(
                '%s <%s>',
                $self->{configuration}->{app}->{name},
                $self->{configuration}->{mailer}->{from}
            ),
            'To'           => $email,
            'Subject'      => sprintf(
                '[%s] %s',
                $self->{configuration}->{app}->{name},
                $self->{lh}->maketext("Test accounts request")
            ),
            'Content-Type' => 'multipart/alternative'
        ],
        parts => [
            Email::MIME->create(
                attributes => {
                    content_type => "text/plain",
                    charset      => 'utf-8',
                    encoding     => 'quoted-printable'
                },
                body_str => $text_content
            ),
            Email::MIME->create(
                attributes => {
                    content_type => "text/html",
                    charset      => 'utf-8',
                    encoding     => 'quoted-printable'
                },
                body_str => $html_content
            ),
        ]
    );

    eval {
        local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
        Email::Sender::Simple->send($message);
    };
    $self->abort(
        log  => "Mail notification error: $EVAL_ERROR",
        user => "mail_notification_failure"
    ) if $EVAL_ERROR;

    $self->{logger}->infof(
        "Token send to %s for entityid=%s;token=%s",
        $email,
        $entityid,
        $token->secret(),
    );

    $self->respond(
        template  => 'complete_challenge.tt2.html',
        data     => {
            action     => 'complete_challenge',
            entityid   => $entityid,
            email      => $email,
        }
    );
}

sub req_create_accounts {
    my ($self, %args) = @_;

    $self->check_authentication(action => 'create_accounts')
        if $self->{configuration}->{app}->{login_url};

    my $entityid = $self->get_parameter(name => 'entityid');
    my $token    = $self->get_parameter(name => 'token');
    my $email    = $self->get_parameter(name => 'email');

    $self->check_token(token => $token, entityid => $entityid);

    my $sp = AccountManager::Entity->new(
        db       => $self->{db},
        entityid => $entityid,
    );
    $self->abort(
        log  => sprintf("No such SP '%s' in database", $entityid),
        user => "no_such_entity"
    ) if !$sp->load(speculative => 1);

    ## create test accounts
    my @accounts;

    my $profiles =
        $self->{configuration}->{$entityid}->{account_profiles} ||
        $self->{configuration}->{service}->{account_profiles};
    my $validity_period =
        $self->{configuration}->{$entityid}->{account_validity_period} ||
        $self->{configuration}->{service}->{account_validity_period};

    my $download_token = AccountManager::Token->new(
        db              => $self->{db},
        email_address   => $email,
        entityid        => $entityid,
        creation_date   => DateTime->now(),
        expiration_date => DateTime->now()->add(hours => $validity_period),
        secret          => AccountManager::Tools::generate_secret(20)
    );

    $self->abort(
        log  => "Failed to save download authentication token",
        user => "internal"
    ) if !$download_token->save();

    my $key = AccountManager::Tools::generate_secret(10);

    foreach my $profile (split(/, */, $profiles)) {
        my $password = AccountManager::Tools::generate_password(10);
        my $account = AccountManager::Account->new(
            db              => $self->{db},
            profile         => $profile,
            entityid        => $entityid,
            scope           => $self->{configuration}->{idp}->{scope},
            password        => $password,
            password_crypt  => AccountManager::Tools::encrypt($password, $key),
            password_hash   => AccountManager::Tools::sha256_hash($password),
            token           => $download_token->secret(),
            creation_date   => DateTime->now(),
            expiration_date => DateTime->now()->add(days => $validity_period)
        );
        next unless $account->save();
        push @accounts, $account;
    }

    $self->abort(
        log  => "Failed to create test accounts for SP $entityid",
        user => "accounts_creation_failure"
    ) if !@accounts;

    ## Update simpleSAMLphp configuration to enable test accounts
    my $accounts = AccountManager::Account->get_accounts(db => $self->{db});

    eval {
        AccountManager::Tools::update_ssp_authsources(
            $self->{configuration}->{setup}->{templates_dir},
            $self->{configuration}->{setup}->{accounts_file},
            $accounts
        );
    };
    $self->abort(
        log  => "Failed to create simpleSAMLphp configuration file: $EVAL_ERROR",
        user => "accounts_creation_failure"
    ) if $EVAL_ERROR;

    $self->{logger}->infof(
        "Token validated for entityid=%s",
        $entityid,
    );

    my $download_url = sprintf(
        "%s?action=download_accounts&entityid=%s&token=%s&key=%s",
        $self->{configuration}->{app}->{url},
        $entityid,
        $download_token->secret(),
        $key
    );

    $self->respond(
        template => 'create_accounts.tt2.html',
        data     => {
            action   => 'create_accounts',
            accounts => \@accounts,
            idp      => {
                name => $self->{configuration}->{idp}->{name},
            },
            sp       => {
                entityid => $entityid,
                url      => $sp->information_url(),
            },
            email        => $email,
            download_url => $download_url,
            days         => $validity_period,
        }
    );
}

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

    $self->check_authentication(action => 'download_accounts')
        if $self->{configuration}->{app}->{login_url};

    my $entityid = $self->get_parameter(name => 'entityid');
    my $token    = $self->get_parameter(name => 'token');
    my $key      = $self->get_parameter(name => 'key');

    $self->check_token(token => $token, entityid => $entityid);

    # load accounts from database
    my $accounts = AccountManager::Account->get_accounts(
        db    => $self->{db},
        query => [
            token => $token
        ],
    );

    foreach my $account (@$accounts) {
        my $password = AccountManager::Tools::decrypt(
            $account->password_crypt(), $key
        );
        $account->password($password);
    }

    binmode(STDOUT, ":encoding(UTF-8)");

    print $self->{cgi}->header(
        -type                => 'text/csv',
        -content_disposition => 'attachment; filename="accounts.csv"'
    );

    my $templates_dir = $self->{configuration}->{setup}->{templates_dir};

    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => [
            sprintf("%s/other", $templates_dir),
            sprintf("%s/accounts", $templates_dir),
        ],
    });

    unless ($tt2->process("accounts.csv.tt2", { accounts => $accounts }, \*STDOUT)) {
        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
    }
}

## Return the homepage of the service
sub req_home {
    my ($self) = @_;

    $self->respond(
        template => 'home.tt2.html',
        data => {
            action => 'home'
        }
    );
}

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

    Net::IP->require();
    my $source_ip_string = get_source_address();
    my $source_ip = Net::IP->new($source_ip_string);
    my @allowed_ips_strings = $self->{configuration}->{status}->{allowed} ?
        split(/, */, $self->{configuration}->{status}->{allowed}) : ();
    my @allowed_ips = map { Net::IP->new($_) } @allowed_ips_strings;

    if (any { $_->overlaps($source_ip) } @allowed_ips) {
        Sys::Hostname->require();
        JSON->require();
        print $self->{cgi}->header(
            -type    => 'application/json',
            -charset => 'utf8',
        );
        my $status =  $self->{configuration}->{status}->{disabled} ? 'disabled' : 'available';
        print JSON->new()->encode({
            status => $status,
            host   => Sys::Hostname::hostname()
        });
    } else {
        $self->{logger}->errorf("Unauthorized access from %s", $source_ip_string);
        print $self->{cgi}->header(
            -status  => '403 unauthorized',
            -type    => 'text/plain',
        );
        print "Unauthorized access";
    }

    exit 0;
}

sub get_parameter {
    my ($self, %args) = @_;

    my $name  = $args{name};
    my $value = $self->{cgi}->param($name);

    $self->abort(
        log  => "Missing parameter: $name",
        user => "missing_$name"
    ) if !$value;

    if ($patterns{$name}) {
        $self->abort(
            log  => "Incorrect parameter format: $name",
            user => "format_$name"
        ) if $value !~ $patterns{$name};
    }

    return $value;
}

sub get_metadata_file {
    my ($self, %args) = @_;

    my $federation = $args{federation};

    my $file = $self->{configuration}->{$federation}->{metadata};

    $self->abort(
        log  => "Incorrect parameter: federation",
        user => "invalid_federation"
    ) if !$file;

    return $file;
}

sub check_token {
    my ($self, %args) = @_;

    my $secret = $args{token};

    my $token = AccountManager::Token->new(
        db     => $self->{db},
        secret => $secret
    );

    $self->abort(
        log  => "No such authentication token $secret",
        user => "wrong_token"
    ) if !$token->load(speculative => 1);

    $self->abort(
        log  => "Authentication token $secret cannot be used for SP $args{entityid}",
        user => "wrong_token_for_sp"
    ) if $token->entityid() ne $args{entityid};

    ## delete the token
    unless ($token->delete()) {
        $self->{logger}->errorf(
            "Failed to delete authentication token %s",
            $secret
        );
    }
}

sub check_authentication {
    my $self = shift;
    my %args = @_;

    $self->abort(
        log  => "unauthenticated user for action $args{action}",
        user => "unauthenticated"
    ) if !$ENV{HTTP_SHIB_IDENTITY_PROVIDER};

    $self->abort(
        log  => "no displayName attribute for identity provider $ENV{HTTP_SHIB_IDENTITY_PROVIDER}",
        user => "no_displayname_attribute"
    ) if !$ENV{'HTTP_DISPLAYNAME'};
}

sub get_source_address {
    return $ENV{HTTP_X_FORWARDED_FOR} ?
        (split(/, /, $ENV{HTTP_X_FORWARDED_FOR}))[0] :
        $ENV{REMOTE_ADDR};
}

1;