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(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;

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

my %actions = (
    home               => 'req_home',
    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.1.1';

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 ($lang = $self->{cgi}->param('lang')) {
        $self->{logger}->debugf("setting language from parameter: %s", $lang);
    } elsif ($lang = $cookies->{lang} ? $cookies->{lang}->value() : undef) {
        $self->{logger}->debugf("setting language from cookie: %s", $lang);
    } elsif ($lang = $ENV{HTTP_ACCEPT_LANGUAGE}) {
        $self->{logger}->debugf("setting language from HTTP_ACCEPT_LANGUAGE header: %s", $lang);
    } else {
        $self->{logger}->debugf("using default language");
    }
    $self->{lh} = AccountManager::L10N->get_handle($lang ? $lang: ());
    if (!$self->{lh}) {
        $self->{logger}->fatal("Unable to get suitable language handle");
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }
    $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}->{groups}->{list}) {
        $self->{logger}->fatal(
            "No federations 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 $theme_templates_dir = sprintf(
        "%s/web/%s",
        $self->{configuration}->{setup}->{templates_dir},
        $self->{configuration}->{setup}->{templates_theme} || 'default'
    );
    my $default_templates_dir = sprintf(
        "%s/web",
        $self->{configuration}->{setup}->{templates_dir},
    );
    my $templates_dir = -d $theme_templates_dir ?
        $theme_templates_dir  :
        $default_templates_dir;

    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => $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 @groups;
    my @organization_entities;

    foreach my $id (split(/, */, $self->{configuration}->{groups}->{list})) {
        my $spec = $self->{configuration}->{$id};
        if ($spec->{type} eq 'metadata') {

            my $metadata;
            eval {
                $metadata = AccountManager::Metadata->new(
                    file => $spec->{file}
                );
            };
            $self->abort(
                log  => "Failed to load federation metadata: $EVAL_ERROR",
                user => "internal"
            ) if $EVAL_ERROR;

            my $entities = $metadata->parse(type => 'sp');
            push @groups, {
                id       => $id,
                label    => $spec->{label},
                type     => 'list',
                entities => [
                    map { {
                        id         => $_->{entityid},
                        name       => $_->{display_name},
                        federation => $id
                     } } @$entities
                ]
            };

            # if user is authenticated, and its IdP is found in metadata,
            # push all entities with the same organization URL in a list
            if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) {
                my $idps = $metadata->parse(id => $ENV{HTTP_SHIB_IDENTITY_PROVIDER});
                my $idp  = $idps->[0];
                if ($idp) {
                    my $organization = $idp->{organization};
                    $self->{logger}->debugf(
                        "idp %s found in federation %s metadata with organization %s",
                        $ENV{HTTP_SHIB_IDENTITY_PROVIDER},
                        $id,
                        $organization
                    );
                    push @organization_entities,
                        map { {
                            id         => $_->{entityid},
                            name       => $_->{display_name},
                            federation => $id
                        } }
                        grep { $_->{organization} eq $organization }
                        @$entities;
                }
            }
        } elsif ($spec->{type} eq 'organization') {
            push @groups, {
                id       => $id,
                label    => $spec->{label},
                type     => 'list',
                entities => \@organization_entities,
            };
        } elsif ($spec->{type} eq 'link') {
            push @groups, {
                id      => $id,
                label   => $spec->{label},
                type    => 'link',
                url     => $spec->{url},
                message => $spec->{message},
            };
        }
    }

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

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 $federation = $self->get_parameter(name => 'federation');

    # Create a persistent service provider object
    my $sp = AccountManager::Entity->new(
        db       => $self->{db},
        entityid => $entityid
    );

    if ($sp->load(speculative => 1)) {
        # already present in DB, nothing todo
    } else {
        # extract information from metadata
        my $file = $self->get_metadata_file(federation => $federation);
        my $metadata;

        eval {
            $metadata = AccountManager::Metadata->new(
                file => $file
            );
        };
        $self->abort(
            log  => "Failed to load federation metadata: $EVAL_ERROR",
            user => "internal"
        ) if $EVAL_ERROR;

        my $entities = $metadata->parse(id => $entityid);
        my $entity = $entities->[0];

        $self->abort(
            log  => "No such SP $entityid in metadata",
            user => "no_such_entity"
        ) if !$entity;

        # complete persistent object
        $sp->displayname($entity->{display_name});
        $sp->url($entity->{url});
        $sp->contacts(uniq map { $_->{EmailAddress} } @{$entity->{contacts}})
            if $entity->{contacts};

        # save in DB
        $self->abort(
            log  => "Failed to save service provider",
            user => "internal"
        ) if !$sp->save();
    }

    # 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',
            federation => $federation,
            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,
        sp_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,
        sp_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 $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,
        idp       => $idp,
        sp        => $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,
        sp_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,
            sp_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,
    );

    $self->respond(
        template => 'create_accounts.tt2.html',
        data     => {
            action   => 'create_accounts',
            accounts => \@accounts,
            entityid => $entityid,
            url      => $sp->information_url(),
            email    => $email,
            key      => $key,
            token    => $download_token->secret(),
            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
        ],
    );

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

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

    Text::CSV->require();
    my $csv = Text::CSV->new({ binary => 1, eol => "\r\n", quote_space => 0 });
    $csv->print(\*STDOUT, [ qw/
        username
        password
        profile
        cn
        displayName
        givenName
        mail
        eduPersonAffiliation
        eduPersonScopedAffiliation
        eduPersonPrincipalName
        schacHomeOrganization
        schacHomeOrganizationType
    / ]);

    foreach my $account (@$accounts) {
        my $password = AccountManager::Tools::decrypt(
            $account->password_crypt(), $key
        );
        $account->password($password);
        $csv->print(\*STDOUT, [
            $account->internal_uid(),
            $account->password(),
            $account->profile(),
            $account->cn(),
            $account->displayName(),
            $account->givenName(),
            $account->mail(),
            join(', ', $account->eduPersonAffiliation()),
            join(', ', $account->eduPersonScopedAffiliation()),
            $account->eduPersonPrincipalName(),
            $account->schacHomeOrganization(),
            $account->schacHomeOrganizationType(),
        ]);
    }
}

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

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

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->sp_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};
}

1;