package AccessCheck::App::Controller;

use Mojo::Base qw(Mojolicious::Controller);

use English qw(-no_match_vars);
use HTTP::AcceptLanguage;
use Syntax::Keyword::Try;
use Template::Constants qw(:chomp);
use UNIVERSAL::require;

use AccessCheck::Data::Account;
use AccessCheck::Data::DB;
use AccessCheck::Data::Entity;
use AccessCheck::Data::Token;
use AccessCheck::L10N;
use AccessCheck::Regexp;
use AccessCheck::Tools;

sub init_l10n {
    my $self = shift;

    my $log = $self->app()->log();

    # lang identification first, as needed for any further error message
    my ($l10n, $lang);
    if ($lang = $self->param('lang')) {
        $log->debug(sprintf("setting language from parameter: %s", $lang));
    } elsif ($lang = $self->session('lang')) {
        $log->debug(sprintf("setting language from session: %s", $lang));
    } elsif (my $header = $self->req()->headers->header('Accept-Language')) {
        $lang = HTTP::AcceptLanguage->new($header)->match(qw/en fr/);
        $log->debug(sprintf("setting language from Accept-Language header: %s", $lang));
    } else {
        $lang = 'en';
    }

    $l10n = AccessCheck::L10N->get_handle($lang);

    $self->session(lang => $lang);
    $self->stash(lang => $lang);
    $self->stash(l10n => $l10n);

    return $l10n;
}

sub init_user {
    my $self = shift;

    my $headers = $self->req()->headers();

    my $idp = 
        $ENV{'Shib_Identity_Provider'} ||           # local SP
        $headers->header('Shib-Identity-Provider'); # remote SP

    my $name =
        $ENV{displayName} ||             # local SP
        $headers->header('displayName'); # remote SP

    my $user = {
        idp => $idp,
        name => $name
    };

    $self->stash(user => $user);

    return $user;
}

sub init_db {
    my $self = shift;

    my $db = AccessCheck::Data::DB->new();

    $self->stash(db => $db);

    return $db;
}

sub check_authentication {
    my $self = shift;

    return $self->abort(
        status       => 401,
        log_message  => sprintf("unauthenticated user for action %s",  $self->current_route()),
        user_message => Registry::Error::AuthenticationRequired->new()
    ) if !$self->stash('user');

    return 1;
}

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

    my $secret = $args{token};
    my $db     = $self->stash('db');

    my $token = AccessCheck::Data::Token->new(
        db     => $db,
        secret => $secret
    );

    return $self->abort(
        status       => 400,
        log_message  => "No such authentication token $secret",
        user_message => "wrong_token"
    ) if !$token->load(speculative => 1);

    return $self->abort(
        status       => 400,
        log_message  => "Authentication token $secret cannot be used for SP $args{entityid}",
        user_message => "wrong_token_for_sp"
    ) if $token->entityid() ne $args{entityid};

    ## delete the token
    try {
        $token->delete();
    } catch {
        $self->app()->log()->error(
            sprintf("Failed to delete authentication token %s", $secret)
        );
    }

    return 1;
}

=head2 check_csrf_token()

Check if provided anti-CSRF token, as I<token> parameter, matches expected
one, abort otherwise.

=cut

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

    my $provided_token = $self->param('token');
    return $self->abort(
        status       => 403,
        log_message  => sprintf("missing anti-CSRF token for action %s", $self->current_route()),
        user_message => "missing_csrf_token"
    ) if !$provided_token;

    my $expected_token = $self->csrf_token();

    return $self->abort(
        status      => 403,
        log_message => sprintf(
            "invalid anti-CSRF token for action %s: %s instead of expected %s",
            $self->current_route(),
            $provided_token,
            $expected_token,
        ),
        user_message => "invalid_csrf_token"
    ) if $provided_token ne $expected_token;

    return 1;
}

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

    my $entityid = $args{entityid};
    my $db       = $self->stash('db');

    return $self->abort(
        log_message  => "Missing parameter: entityid",
        user_message => "missing_entityid"
    ) if !$entityid;

    return $self->abort(
        log_message  => "Invalid parameter: entityid",
        user_message => "invalid_entityid"
    ) if $entityid !~ $AccessCheck::Regexp::entityid;


    my $sp = AccessCheck::Data::Entity->new(
        db       => $db,
        entityid => $entityid
    );

    return $self->abort(
        log_message  => sprintf("No such SP '%s' in database", $entityid),
        user_message => "no_such_entity"
    ) if !$sp->load(speculative => 1);

    return $sp;
}

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

    my $status = $args{status} || 200;
    my $format = $args{format} || 'html';
    my $db     = $self->stash('db');

    $db->rollback() if $db && $db->in_transaction();

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

    $self->stash(error => $args{user_message});
    $self->render(status => $status, template => 'errors', format => 'html');

    return;
}

sub loc {
    my $self = shift;

    return $self->stash('l10n')->maketext(@_);
}

sub mock_contacts {
    my $self = shift;
    my $sp = shift;

    my $config    = $self->app()->config();
    my $entityid = $sp->entityid();

    my $contacts =
        $config->{$entityid}->{contacts} ||
        $config->{service}->{contacts};

    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $sp->contacts($sp->contacts(), $self->string_to_list($1));
        } else {
            # replace original contacts
            $sp->contacts($self->string_to_list($contacts));
        }
    }
}

sub home {
    my $self = shift;

    $self->init_l10n();

    $self->render(status => 200, template => 'home', format => 'html');
}

=head2 status()

Return the health status of the frontend.

=cut

sub status {
    my $self  = shift;

    my $config = $self->app()->config();

    if (!$config->{status}) {
        $self->render(
            status => 403,
            text   => "unauthorized access"
        );
        return;
    }

    List::MoreUtils->require();
    Mojo::Util->require();

    my $client_ip   = $self->forwarded_for();
    my @allowed_ips = $self->string_to_list($config->{status}->{allowed});

    if (List::MoreUtils::none { Mojo::Util::network_contains($_, $client_ip) } @allowed_ips) {
        $self->render(
            status => 403,
            text   => "unauthorized access"
        );
        return;
    }

    Sys::Hostname->require();
    my $status = $config->{status}->{disabled} ? 'disabled' : 'available';
    
    my $health = {
        status => $status,
        host   => hostname(),
    };

    $self->render(status => 200, json => $health);
}

sub select_entity {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

    my $sps = AccessCheck::Data::Entity->get_entities(
        db    => $db,
        query => [
            type => 'sp',
        ],
        sort_by => 'display_name'
    );

    my $idp;
    if ($user) {
        my $idps = AccessCheck::Data::Entity->get_entities(
            db    => $db,
            query => [
                type     => 'idp',
                entityid => $user->{idp}
            ]
        );
        $idp = $idps->[0];
    }

    $self->stash(sps => $sps);
    $self->stash(idp => $idp);

    $self->render(
        status   => 200,
        template => 'select_entity',
        format   => 'html'
    );
}

sub select_email {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

    my $entityid = $self->param('entityid');
    my $sp = $self->get_sp(entityid => $entityid);
    return if !$sp;

    # override metadata contacts if needed
    $self->mock_contacts($sp);

    $self->stash(sp => $sp);
    $self->stash(entityid => $entityid);

    $self->render(
        status   => 200,
        template => 'select_email',
        format   => 'html'
    );
}

sub send_challenge {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

    return if !$self->check_csrf_token();

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

    my $sp = $self->get_sp(entityid => $entityid);
    return if !$sp;

    return $self->abort(
        log_message  => "Missing parameter: email",
        user_message => "missing_email"
    ) if !$email;

    return $self->abort(
        log_message  => "Invalid parameter: email",
        user_message => "invalid_email"
    ) if $email !~ $AccessCheck::Regexp::email;

    # override metadata contacts if needed
    $self->mock_contacts($sp);

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

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

    if ($old_token->load(speculative => 1)) {
        try {
            $old_token->delete();
        } catch  {
            return $self->abort(
                log_message  => "Failed to delete old authentication token",
                user_message => "internal"
            );
        }
    }

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

    try {
        $token->save();
    } catch {
        return $self->abort(
            log_message  => "Failed to save creation authentication token",
            user_message => "internal"
        );
    }

    # build content
    my $theme              = $config->{setup}->{templates_theme} || 'default';
    my $base_templates_dir = $self->app()->home()->child('templates');
    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => [
            $base_templates_dir->child('mail', $theme),
            $base_templates_dir->child('mail'),
        ]
    });

    my $data = {
        app => {
            url           => $config->{app}->{url},
            support_email => $config->{app}->{support_email},
            version       => $config->{app}->{version},
            name          => $config->{app}->{name},
        },
        user          => $user->{name},
        source_ip     => $self->forwarded_for(),
        idp           => { entityid => $user->{idp}, },
        sp            => { entityid => $entityid, },
        to            => $email,
        token         => $token->secret(),
        challenge_url => $self->url_for('validate_challenge')->query(entityid => $entityid, email => $email)->to_abs(),
        lh            => $l10n
    };
    my $text_content;
    my $html_content;
    $tt2->process('send_challenge.txt.tt2',  $data, \$text_content);
    $tt2->process('send_challenge.html.tt2', $data, \$html_content);

    Email::MIME->require();
    Email::Sender::Simple->require();
    my $message = Email::MIME->create(
        header_str => [
            'From'         => sprintf('%s <%s>', $config->{app}->{name}, $config->{mailer}->{from}),
            'To'           => $email,
            'Subject'      => sprintf('[%s] %s', $config->{app}->{name}, $l10n->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
            ),
        ]
    );

    try {
        local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
        Email::Sender::Simple->send($message);
    } catch($error) {
        return $self->abort(
            log_message  => "Mail notification error: $error",
            user_message => "mail_notification_failure"
        );
    }

    $log->info(
        sprintf(
            "Token %s send to %s for entity %s",
            $token->secret(),
            $email,
            $entityid,
        )
    );

    $self->redirect_to('validate_challenge', email => $email, entityid => $entityid);
}

sub validate_challenge {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

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

    my $sp = $self->get_sp(entityid => $entityid);
    return if !$sp;

    return $self->abort(
        log_message  => "Missing parameter: email",
        user_message => "missing_email"
    ) if !$email;

    return $self->abort(
        log_message  => "Invalid parameter: email",
        user_message => "invalid_email"
    ) if $email !~ $AccessCheck::Regexp::email;

    my $base_templates_dir = $self->app()->home()->child('templates');
    my $profiles = $base_templates_dir
        ->child('accounts')
        ->list()
        ->map(sub { m/([^\/]+).tt2$/})
        ->to_array();

    $self->stash(entityid => $entityid);
    $self->stash(email    => $email);
    $self->stash(validity => $config->{service}->{account_validity_period});
    $self->stash(profiles => $profiles);

    $self->render(
        status   => 200,
        template => 'validate_challenge',
        format   => 'html'
    );
}

sub display_accounts_html {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

    my $entityid = $self->param('entityid');
    my $email    = $self->param('email');
    my $token    = $self->param('token');
    my $validity = $self->param('validity');
    my $profiles = $self->every_param('profiles');

    my $sp = $self->get_sp(entityid => $entityid);
    return if !$sp;

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

    ## create test accounts
    my @accounts;

    DateTime->require();
    my $creation_date  = DateTime->now();
    my $token_expiration_date = DateTime->now()->add(
        hours => $config->{service}->{tokens_validity_period}
    );
    my $account_expiration_date = DateTime->now()->add(
        days => $validity
    );

    my $download_token = AccessCheck::Data::Token->new(
        db              => $db,
        email_address   => $email,
        entityid        => $entityid,
        creation_date   => $creation_date,
        expiration_date => $token_expiration_date,
        secret          => AccessCheck::Tools::generate_secret(20)
    );

    try {
        $download_token->save();
    } catch {
        return $self->abort(
            log_message  => "Failed to save download authentication token",
            user_message => "internal"
        );
    }

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

    foreach my $profile (@$profiles) {
        my $password = AccessCheck::Tools::generate_password(10);
        my $account = AccessCheck::Data::Account->new(
            db              => $db,
            profile         => $profile,
            entityid        => $entityid,
            scope           => $config->{idp}->{scope},
            password        => $password,
            password_crypt  => AccessCheck::Tools::encrypt($password, $key),
            password_hash   => AccessCheck::Tools::hash($password),
            token           => $download_token->secret(),
            creation_date   => $creation_date,
            expiration_date => $account_expiration_date,
        );
        next unless $account->save();
        push @accounts, $account;
    }

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

    ## Update simpleSAMLphp configuration to enable test accounts
    my $accounts = AccessCheck::Data::Account->get_accounts(db => $db);

    try {
        AccessCheck::Tools::update_ssp_authsources(
            $self->app()->home()->child('templates'),
            $config->{setup}->{accounts_file},
            $accounts
        );
    } catch($error) {
        return $self->abort(
            log_message  => "Failed to create simpleSAMLphp configuration file: $error",
            user_message => "accounts_creation_failure"
        );
    }

    $log->info(sprintf("Token validated for entityid=%s", $entityid));

    $self->stash(accounts => \@accounts);
    $self->stash(idp      => { name => $config->{idp}->{name} });
    $self->stash(sp       => { entityid => $entityid, url => $sp->information_url() });
    $self->stash(email    => $email);
    $self->stash(days     => $validity);
    $self->stash(
        download_url => $self->url_for('show_accounts_csv')->query(
            entityid => $entityid,
            token    => $download_token->secret(),
            key      => $key
        )
    );

    $self->render(
        status   => 200,
        template => 'show_accounts',
        format   => 'html'
    );
}

sub display_accounts_csv {
    my $self = shift;

    my $app    = $self->app();
    my $config = $app->config();
    my $log    = $app->log();

    my $l10n = $self->init_l10n();
    my $user = $self->init_user();
    my $db   = $self->init_db();

    if ($config->{app}->{login_url}) {
        return if !$self->check_authentication();
    }

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

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

    # load accounts from database
    my $accounts = AccessCheck::Data::Account->get_accounts(
        db    => $db,
        query => [
            token => $token
        ],
    );

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

    $app->types()->type(csv => 'text/csv');

    $self->stash(accounts => $accounts);

    $self->render(
        status   => 200,
        template => 'accounts',
        format   => 'csv'
    );
}

1;