package Travelynx::Controller::Account;

# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';

use JSON;
use Math::Polygon;
use Mojo::Util qw(xml_escape);
use Text::Markdown;
use UUID::Tiny qw(:std);

my %visibility_itoa = (
	100 => 'public',
	80  => 'travelynx',
	60  => 'followers',
	30  => 'unlisted',
	10  => 'private',
);

my %visibility_atoi = (
	public    => 100,
	travelynx => 80,
	followers => 60,
	unlisted  => 30,
	private   => 10,
);

# Internal Helpers

sub make_token {
	return create_uuid_as_string(UUID_V4);
}

sub send_registration_mail {
	my ( $self, %opt ) = @_;

	my $email   = $opt{email};
	my $token   = $opt{token};
	my $user    = $opt{user};
	my $user_id = $opt{user_id};
	my $ip      = $opt{ip};
	my $date    = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	my $ua          = $self->req->headers->user_agent;
	my $reg_url     = $self->url_for('reg')->to_abs->scheme('https');
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo, ${user}!\n\n";
	$body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
	$body .= "travelynx angelegt.\n\n";
	$body
	  .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
	$body .= "${reg_url}/${user_id}/${token}\n";
	$body .= "freischalten.\n\n";
	$body
	  .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
	$body
	  .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
	$body
	  .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
	$body .= "Daten zur Registrierung:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	return $self->sendmail->custom( $email, 'Registrierung bei travelynx',
		$body );
}

sub send_address_confirmation_mail {
	my ( $self, $email, $token ) = @_;

	my $name = $self->current_user->{name};
	my $ip   = $self->req->headers->header('X-Forwarded-For');
	my $ua   = $self->req->headers->user_agent;
	my $date = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
	my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo ${name},\n\n";
	$body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
	$body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
	$body
	  .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
	$body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
	$body .= "Daten zur Anfrage:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	return $self->sendmail->custom( $email,
		'travelynx: Mail-Adresse bestätigen', $body );
}

sub send_name_notification_mail {
	my ( $self, $old_name, $new_name ) = @_;

	my $ip   = $self->req->headers->header('X-Forwarded-For');
	my $ua   = $self->req->headers->user_agent;
	my $date = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
	my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo ${new_name},\n\n";
	$body .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
	$body
	  .= "Bitte beachte, dass du dich ab sofort nur mit dem neuen Namen anmelden kannst.\n\n";
	$body .= "Alter Name: ${old_name}\n\n";
	$body .= "Neue Name: ${new_name}\n\n";
	$body .= "Daten zur Anfrage:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	return $self->sendmail->custom( $self->current_user->{email},
		'travelynx: Name geändert', $body );
}

sub send_password_notification_mail {
	my ($self) = @_;
	my $user   = $self->current_user->{name};
	my $email  = $self->current_user->{email};
	my $ip     = $self->req->headers->header('X-Forwarded-For');
	my $ua     = $self->req->headers->user_agent;
	my $date   = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo ${user},\n\n";
	$body
	  .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
	$body .= "Daten zur Änderung:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	$self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
}

sub send_lostpassword_confirmation_mail {
	my ( $self, %opt ) = @_;
	my $email = $opt{email};
	my $name  = $opt{name};
	my $uid   = $opt{uid};
	my $token = $opt{token};

	my $ip   = $self->req->headers->header('X-Forwarded-For');
	my $ua   = $self->req->headers->user_agent;
	my $date = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
	my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo ${name},\n\n";
	$body .= "Unter ${recover_url}/${uid}/${token}\n";
	$body
	  .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
	$body
	  .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
	$body
	  .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
	$body .= "ausging, kannst du sie ignorieren.\n\n";
	$body .= "Daten zur Anfrage:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	my $success
	  = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', $body );
}

sub send_lostpassword_notification_mail {
	my ( $self, $account ) = @_;
	my $user  = $account->{name};
	my $email = $account->{email};
	my $ip    = $self->req->headers->header('X-Forwarded-For');
	my $ua    = $self->req->headers->user_agent;
	my $date  = DateTime->now( time_zone => 'Europe/Berlin' )
	  ->strftime('%d.%m.%Y %H:%M:%S %z');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
	my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

	my $body = "Hallo ${user},\n\n";
	$body .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
	$body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
	$body .= "Daten zur Änderung:\n";
	$body .= " * Datum: ${date}\n";
	$body .= " * Client: ${ip}\n";
	$body .= " * UserAgent: ${ua}\n\n\n";
	$body .= "Impressum: ${imprint_url}\n";

	return $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
		$body );
}

# Controllers

sub login_form {
	my ($self) = @_;
	$self->render('login');
}

sub do_login {
	my ($self)   = @_;
	my $user     = $self->req->param('user');
	my $password = $self->req->param('password');

	# Keep cookies for 6 months
	$self->session( expiration => 60 * 60 * 24 * 180 );

	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
	}
	else {
		if ( $self->authenticate( $user, $password ) ) {
			$self->redirect_to( $self->req->param('redirect_to') // '/' );
			$self->users->mark_seen( uid => $self->current_user->{id} );
		}
		else {
			my $data = $self->users->get_login_data( name => $user );
			if ( $data and $data->{status} == 0 ) {
				$self->render(
					'login',
					status  => 400,
					invalid => 'confirmation'
				);
			}
			else {
				$self->render(
					'login',
					status  => 400,
					invalid => 'credentials'
				);
			}
		}
	}
}

sub registration_form {
	my ($self) = @_;
	$self->render('register');
}

sub register {
	my ($self)    = @_;
	my $dt        = $self->req->param('dt');
	my $user      = $self->req->param('user');
	my $email     = $self->req->param('email');
	my $password  = $self->req->param('password');
	my $password2 = $self->req->param('password2');
	my $ip        = $self->req->headers->header('X-Forwarded-For');

	# In case Mojolicious is not running behind a reverse proxy
	$ip
	  //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );

	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
		return;
	}

	if ( my $registration_denylist
		= $self->app->config->{registration}->{denylist} )
	{
		if ( open( my $fh, "<", $registration_denylist ) ) {
			while ( my $line = <$fh> ) {
				chomp $line;
				if ( $ip eq $line ) {
					close($fh);
					$self->render( 'register', invalid => "denylist" );
					return;
				}
			}
			close($fh);
		}
		else {
			$self->log->error("Cannot open($registration_denylist): $!");
			die("Cannot verify registration: $!");
		}
	}

	if ( my $error = $self->users->is_name_invalid( name => $user ) ) {
		$self->render( 'register', invalid => $error );
		return;
	}

	if ( not length($email) ) {
		$self->render( 'register', invalid => 'mail_empty' );
		return;
	}

	if ( $self->users->mail_is_blacklisted( email => $email ) ) {
		$self->render( 'register', invalid => 'mail_blacklisted' );
		return;
	}

	if ( $password ne $password2 ) {
		$self->render( 'register', invalid => 'password_notequal' );
		return;
	}

	if ( length($password) < 8 ) {
		$self->render( 'register', invalid => 'password_short' );
		return;
	}

	if ( not $dt
		or DateTime->now( time_zone => 'Europe/Berlin' )->epoch - $dt < 6 )
	{
		# a human user should take at least five seconds to fill out the form.
		# Throw a CSRF error at presumed spammers.
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
		return;
	}

	my $token   = make_token();
	my $db      = $self->pg->db;
	my $tx      = $db->begin;
	my $user_id = $self->users->add(
		db       => $db,
		name     => $user,
		email    => $email,
		token    => $token,
		password => $password,
	);

	my $success = $self->send_registration_mail(
		email   => $email,
		token   => $token,
		ip      => $ip,
		user    => $user,
		user_id => $user_id
	);
	if ($success) {
		$tx->commit;
		$self->render( 'login', from => 'register' );
	}
	else {
		$self->render( 'register', invalid => 'sendmail' );
	}
}

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

	my $id    = $self->stash('id');
	my $token = $self->stash('token');

	if ( not $id =~ m{ ^ \d+ $ }x or $id > 2147483647 ) {
		$self->render( 'register', invalid => 'token' );
		return;
	}

	if (
		not $self->users->verify_registration_token(
			uid   => $id,
			token => $token
		)
	  )
	{
		$self->render( 'register', invalid => 'token' );
		return;
	}

	$self->render( 'login', from => 'verification' );
}

sub delete {
	my ($self) = @_;
	my $uid = $self->current_user->{id};
	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
		return;
	}

	if ( $self->param('action') eq 'delete' ) {
		if (
			not $self->authenticate(
				$self->current_user->{name},
				$self->param('password')
			)
		  )
		{
			$self->flash( invalid => 'deletion password' );
			$self->redirect_to('account');
			return;
		}
		$self->users->flag_deletion( uid => $uid );
	}
	else {
		$self->users->unflag_deletion( uid => $uid );
	}
	$self->redirect_to('account');
}

sub do_logout {
	my ($self) = @_;
	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
		return;
	}
	$self->logout;
	$self->redirect_to('/login');
}

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

	my $user = $self->current_user;

	if ( $self->param('action') and $self->param('action') eq 'save' ) {
		my %opt;
		my $default_visibility
		  = $visibility_atoi{ $self->param('status_level') };
		if ( defined $default_visibility ) {
			$opt{default_visibility} = $default_visibility;
		}

		my $past_visibility = $visibility_atoi{ $self->param('history_level') };
		if ( defined $past_visibility ) {
			$opt{past_visibility} = $past_visibility;
		}

		$opt{comments_visible} = $self->param('public_comment') ? 1 : 0;

		$opt{past_all}    = $self->param('history_age') eq 'infinite' ? 1 : 0;
		$opt{past_status} = $self->param('past_status')               ? 1 : 0;

		$self->users->set_privacy(
			uid => $user->{id},
			%opt
		);

		$self->flash( success => 'privacy' );
		$self->redirect_to('account');
	}
	else {
		$self->param(
			status_level => $visibility_itoa{ $user->{default_visibility} } );
		$self->param( public_comment => $user->{comments_visible} );
		$self->param(
			history_level => $visibility_itoa{ $user->{past_visibility} } );
		$self->param( history_age => $user->{past_all} ? 'infinite' : 'month' );
		$self->param( past_status => $user->{past_status} );
		$self->render( 'privacy', name => $user->{name} );
	}
}

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

	my $user = $self->current_user;

	if ( $self->param('action') and $self->param('action') eq 'save' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}

		my %opt;
		my $accept_follow = $self->param('accept_follow');

		if ( $accept_follow eq 'yes' ) {
			$opt{accept_follows} = 1;
		}
		elsif ( $accept_follow eq 'request' ) {
			$opt{accept_follow_requests} = 1;
		}

		$self->users->set_social(
			uid => $user->{id},
			%opt
		);

		$self->flash( success => 'social' );
		$self->redirect_to('account');
	}
	else {
		if ( $user->{accept_follows} ) {
			$self->param( accept_follow => 'yes' );
		}
		elsif ( $user->{accept_follow_requests} ) {
			$self->param( accept_follow => 'request' );
		}
		else {
			$self->param( accept_follow => 'no' );
		}
		$self->render( 'social', name => $user->{name} );
	}
}

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

	my $kind = $self->stash('kind');
	my $user = $self->current_user;

	if ( $kind eq 'follow-requests-received' ) {
		my @follow_reqs
		  = $self->users->get_follow_requests( uid => $user->{id} );
		$self->render(
			'social_list',
			type          => 'follow-requests-received',
			entries       => [@follow_reqs],
			notifications => $user->{notifications},
		);
	}
	elsif ( $kind eq 'follow-requests-sent' ) {
		my @follow_reqs = $self->users->get_follow_requests(
			uid  => $user->{id},
			sent => 1
		);
		$self->render(
			'social_list',
			type          => 'follow-requests-sent',
			entries       => [@follow_reqs],
			notifications => $user->{notifications},
		);
	}
	elsif ( $kind eq 'followers' ) {
		my @followers = $self->users->get_followers( uid => $user->{id} );
		$self->render(
			'social_list',
			type          => 'followers',
			entries       => [@followers],
			notifications => $user->{notifications},
		);
	}
	elsif ( $kind eq 'follows' ) {
		my @following = $self->users->get_followees( uid => $user->{id} );
		$self->render(
			'social_list',
			type          => 'follows',
			entries       => [@following],
			notifications => $user->{notifications},
		);
	}
	elsif ( $kind eq 'blocks' ) {
		my @blocked = $self->users->get_blocked_users( uid => $user->{id} );
		$self->render(
			'social_list',
			type          => 'blocks',
			entries       => [@blocked],
			notifications => $user->{notifications},
		);
	}
	else {
		$self->render( 'not_found', status => 404 );
	}
}

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

	my $user        = $self->current_user;
	my $action      = $self->param('action');
	my $target_ids  = $self->param('target');
	my $redirect_to = $self->param('redirect_to');

	for my $key (
		qw(follow request_follow follow_or_request unfollow remove_follower cancel_follow_request accept_follow_request reject_follow_request block unblock)
	  )
	{
		if ( $self->param($key) ) {
			$action     = $key;
			$target_ids = $self->param($key);
		}
	}

	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->redirect_to('/');
		return;
	}

	if ( $action and $action eq 'clear_notifications' ) {
		$self->users->update_notifications(
			db                  => $self->pg->db,
			uid                 => $user->{id},
			has_follow_requests => 0
		);
		$self->flash( success => 'clear_notifications' );
		$self->redirect_to('account');
		return;
	}

	if ( not( $action and $target_ids and $redirect_to ) ) {
		$self->redirect_to('/');
		return;
	}

	for my $target_id ( split( qr{,}, $target_ids ) ) {
		my $target = $self->users->get_privacy_by( uid => $target_id );

		if ( not $target ) {
			next;
		}

		if ( $action eq 'follow' and $target->{accept_follows} ) {
			$self->users->follow(
				uid    => $user->{id},
				target => $target->{id}
			);
		}
		elsif ( $action eq 'request_follow'
			and $target->{accept_follow_requests} )
		{
			$self->users->request_follow(
				uid    => $user->{id},
				target => $target->{id}
			);
		}
		elsif ( $action eq 'follow_or_request' ) {
			if ( $target->{accept_follows} ) {
				$self->users->follow(
					uid    => $user->{id},
					target => $target->{id}
				);
			}
			elsif ( $target->{accept_follow_requests} ) {
				$self->users->request_follow(
					uid    => $user->{id},
					target => $target->{id}
				);
			}
		}
		elsif ( $action eq 'unfollow' ) {
			$self->users->unfollow(
				uid    => $user->{id},
				target => $target->{id}
			);
		}
		elsif ( $action eq 'remove_follower' ) {
			$self->users->remove_follower(
				uid      => $user->{id},
				follower => $target->{id}
			);
		}
		elsif ( $action eq 'cancel_follow_request' ) {
			$self->users->cancel_follow_request(
				uid    => $user->{id},
				target => $target->{id}
			);
		}
		elsif ( $action eq 'accept_follow_request' ) {
			$self->users->accept_follow_request(
				uid       => $user->{id},
				applicant => $target->{id}
			);
		}
		elsif ( $action eq 'reject_follow_request' ) {
			$self->users->reject_follow_request(
				uid       => $user->{id},
				applicant => $target->{id}
			);
		}
		elsif ( $action eq 'block' ) {
			$self->users->block(
				uid    => $user->{id},
				target => $target->{id}
			);
		}
		elsif ( $action eq 'unblock' ) {
			$self->users->unblock(
				uid    => $user->{id},
				target => $target->{id}
			);
		}

		if ( $redirect_to eq 'profile' ) {

			# profile links do not perform bulk actions
			$self->redirect_to( '/p/' . $target->{name} );
			return;
		}
	}

	$self->redirect_to($redirect_to);
}

sub profile {
	my ($self) = @_;
	my $user = $self->current_user;

	if ( $self->param('action') and $self->param('action') eq 'save' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}
		my $md  = Text::Markdown->new;
		my $bio = $self->param('bio');

		if ( length($bio) > 2000 ) {
			$bio = substr( $bio, 0, 2000 ) . '…';
		}

		my $profile = {
			bio => {
				markdown => $bio,
				html     => $md->markdown( xml_escape($bio) ),
			},
			metadata => [],
		};
		for my $i ( 0 .. 20 ) {
			my $key   = $self->param("key_$i");
			my $value = $self->param("value_$i");
			if ($key) {
				if ( length($value) > 500 ) {
					$value = substr( $value, 0, 500 ) . '…';
				}
				my $html_value
				  = ( $value
					  =~ s{ \[ ([^]]+) \]\( ([^)]+) \) }{'<a href="' . xml_escape($2) . '" rel="me">' . xml_escape($1) .'</a>' }egrx
				  );
				$profile->{metadata}[$i] = {
					key   => $key,
					value => {
						markdown => $value,
						html     => $html_value,
					},
				};
			}
			else {
				last;
			}
		}
		$self->users->set_profile(
			uid     => $user->{id},
			profile => $profile
		);
		$self->redirect_to( '/p/' . $user->{name} );
	}

	my $profile = $self->users->get_profile( uid => $user->{id} );
	$self->param( bio => $profile->{bio}{markdown} );
	for my $i ( 0 .. $#{ $profile->{metadata} } ) {
		$self->param( "key_$i"   => $profile->{metadata}[$i]{key} );
		$self->param( "value_$i" => $profile->{metadata}[$i]{value}{markdown} );
	}

	$self->render( 'edit_profile', name => $user->{name} );
}

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

	my $user        = $self->current_user;
	my $use_history = $self->users->use_history( uid => $user->{id} );

	if ( $self->param('action') and $self->param('action') eq 'save' ) {
		if ( $self->param('on_departure') ) {
			$use_history |= 0x01;
		}
		else {
			$use_history &= ~0x01;
		}

		if ( $self->param('on_arrival') ) {
			$use_history |= 0x02;
		}
		else {
			$use_history &= ~0x02;
		}

		$self->users->use_history(
			uid => $user->{id},
			set => $use_history
		);
		$self->flash( success => 'use_history' );
		$self->redirect_to('account');
	}

	$self->param( on_departure => $use_history & 0x01 ? 1 : 0 );
	$self->param( on_arrival   => $use_history & 0x02 ? 1 : 0 );
	$self->render('use_history');

}

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

	my $uid = $self->current_user->{id};

	my $hook = $self->users->get_webhook( uid => $uid );

	if ( $self->param('action') and $self->param('action') eq 'save' ) {
		$hook->{url}     = $self->param('url');
		$hook->{token}   = $self->param('token');
		$hook->{enabled} = $self->param('enabled') // 0;
		$self->users->set_webhook(
			uid     => $uid,
			url     => $hook->{url},
			token   => $hook->{token},
			enabled => $hook->{enabled}
		);
		$self->run_hook(
			$self->current_user->{id},
			'ping',
			sub {
				$self->render(
					'webhooks',
					hook     => $self->users->get_webhook( uid => $uid ),
					new_hook => 1
				);
			}
		);
		return;
	}
	else {
		$self->param( url     => $hook->{url} );
		$self->param( token   => $hook->{token} );
		$self->param( enabled => $hook->{enabled} );
	}

	$self->render( 'webhooks', hook => $hook );
}

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

	my $action   = $self->req->param('action');
	my $password = $self->req->param('password');
	my $email    = $self->req->param('email');

	if ( $action and $action eq 'update_mail' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}

		if ( not length($email) ) {
			$self->render( 'change_mail', invalid => 'mail_empty' );
			return;
		}

		if (
			not $self->authenticate(
				$self->current_user->{name},
				$self->param('password')
			)
		  )
		{
			$self->render( 'change_mail', invalid => 'password' );
			return;
		}

		my $token = make_token();
		my $db    = $self->pg->db;
		my $tx    = $db->begin;

		$self->users->mark_for_mail_change(
			db    => $db,
			uid   => $self->current_user->{id},
			email => $email,
			token => $token
		);

		my $success = $self->send_address_confirmation_mail( $email, $token );

		if ($success) {
			$tx->commit;
			$self->render( 'change_mail', success => 1 );
		}
		else {
			$self->render( 'change_mail', invalid => 'sendmail' );
		}
	}
	else {
		$self->render('change_mail');
	}
}

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

	my $action   = $self->req->param('action');
	my $password = $self->req->param('password');
	my $old_name = $self->current_user->{name};
	my $new_name = $self->req->param('name');

	if ( $action and $action eq 'update_name' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}

		if ( my $error = $self->users->is_name_invalid( name => $new_name ) ) {
			$self->render(
				'change_name',
				name    => $old_name,
				invalid => $error
			);
			return;
		}

		if ( not $self->authenticate( $old_name, $self->param('password') ) ) {
			$self->render(
				'change_name',
				name    => $old_name,
				invalid => 'password'
			);
			return;
		}

		# The users table has a unique constraint on the "name" column, so having
		# two users with the same name is not possible. The race condition
		# between the user_name_exists check in is_name_invalid and this
		# change_name call is harmless.
		my $success = $self->users->change_name(
			uid  => $self->current_user->{id},
			name => $new_name
		);

		if ( not $success ) {
			$self->render(
				'change_name',
				name    => $old_name,
				invalid => 'user_collision'
			);
			return;
		}

		$self->flash( success => 'name' );
		$self->redirect_to('account');

		$self->send_name_notification_mail( $old_name, $new_name );
	}
	else {
		$self->render( 'change_name', name => $old_name );
	}
}

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

	$self->render('change_password');
}

sub lonlat_in_polygon {
	my ( $self, $polygon, $lonlat ) = @_;

	my $circle = shift( @{$polygon} );
	my @holes  = @{$polygon};

	my $circle_poly = Math::Polygon->new( @{$circle} );
	if ( $circle_poly->contains($lonlat) ) {
		for my $hole (@holes) {
			my $hole_poly = Math::Polygon->new( @{$hole} );
			if ( $hole_poly->contains($lonlat) ) {
				return;
			}
		}
		return 1;
	}
	return;
}

sub backend_form {
	my ($self) = @_;
	my $user = $self->current_user;

	my @backends = $self->stations->get_backends;
	my @suggested_backends;

	my %place_map = (
		AT       => 'Österreich',
		CH       => 'Schweiz',
		'CH-BE'  => 'Kanton Bern',
		'CH-GE'  => 'Kanton Genf',
		'CH-LU'  => 'Kanton Luzern',
		'CH-ZH'  => 'Kanton Zürich',
		DE       => 'Deutschland',
		'DE-BB'  => 'Brandenburg',
		'DE-BW'  => 'Baden-Württemberg',
		'DE-BE'  => 'Berlin',
		'DE-BY'  => 'Bayern',
		'DE-HB'  => 'Bremen',
		'DE-HE'  => 'Hessen',
		'DE-MV'  => 'Mecklenburg-Vorpommern',
		'DE-NI'  => 'Niedersachsen',
		'DE-NW'  => 'Nordrhein-Westfalen',
		'DE-RP'  => 'Rheinland-Pfalz',
		'DE-SH'  => 'Schleswig-Holstein',
		'DE-ST'  => 'Sachsen-Anhalt',
		'DE-TH'  => 'Thüringen',
		DK       => 'Dänemark',
		'GB-NIR' => 'Nordirland',
		LI       => 'Liechtenstein',
		LU       => 'Luxembourg',
		IE       => 'Irland',
		'US-CA'  => 'California',
		'US-TX'  => 'Texas',
	);

	my ( $user_lat, $user_lon )
	  = $self->journeys->get_latest_checkout_latlon( uid => $user->{id} );

	for my $backend (@backends) {
		my $type = 'UNKNOWN';
		if ( $backend->{iris} ) {
			$type                = 'IRIS-TTS';
			$backend->{name}     = 'DB';
			$backend->{longname} = 'Deutsche Bahn (IRIS-TTS)';
			$backend->{homepage} = 'https://www.bahn.de';
		}
		elsif ( $backend->{hafas} ) {
			if ( my $s = $self->hafas->get_service( $backend->{name} ) ) {
				$type                = 'HAFAS';
				$backend->{longname} = $s->{name};
				$backend->{homepage} = $s->{homepage};
				$backend->{regions}  = [ map { $place_map{$_} // $_ }
					  @{ $s->{coverage}{regions} // [] } ];
				$backend->{has_area} = $s->{coverage}{area} ? 1 : 0;

				if (
					    $s->{coverage}{area}
					and $s->{coverage}{area}{type} eq 'Polygon'
					and $self->lonlat_in_polygon(
						$s->{coverage}{area}{coordinates},
						[ $user_lon, $user_lat ]
					)
				  )
				{
					push( @suggested_backends, $backend );
				}
				elsif ( $s->{coverage}{area}
					and $s->{coverage}{area}{type} eq 'MultiPolygon' )
				{
					for my $s_poly (
						@{ $s->{coverage}{area}{coordinates} // [] } )
					{
						if (
							$self->lonlat_in_polygon(
								$s_poly, [ $user_lon, $user_lat ]
							)
						  )
						{
							push( @suggested_backends, $backend );
							last;
						}
					}
				}
			}
			else {
				$type = undef;
			}
		}
		$backend->{type} = $type;
	}

	# These backends lack a journey endpoint and are useless for travelynx
	@backends
	  = grep { $_->{name} ne 'Resrobot' and $_->{name} ne 'TPG' } @backends;

	my $iris = shift @backends;

	@backends
	  = sort { $a->{name} cmp $b->{name} } grep { $_->{type} } @backends;

	unshift( @backends, $iris );

	$self->render(
		'select_backend',
		suggestions => \@suggested_backends,
		backends    => \@backends,
		user        => $user,
		redirect_to => $self->req->param('redirect_to') // '/',
	);
}

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

	my $backend_id = $self->req->param('backend');
	my $redir      = $self->req->param('redirect_to') // '/';

	if ( $backend_id !~ m{ ^ \d+ $ }x ) {
		$self->redirect_to($redir);
	}

	$self->users->set_backend(
		uid        => $self->current_user->{id},
		backend_id => $backend_id,
	);

	$self->redirect_to($redir);
}

sub change_password {
	my ($self)       = @_;
	my $old_password = $self->req->param('oldpw');
	my $password     = $self->req->param('newpw');
	my $password2    = $self->req->param('newpw2');

	if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
		$self->render(
			'bad_request',
			csrf   => 1,
			status => 400
		);
		return;
	}

	if ( $password ne $password2 ) {
		$self->render( 'change_password', invalid => 'password_notequal' );
		return;
	}

	if ( length($password) < 8 ) {
		$self->render( 'change_password', invalid => 'password_short' );
		return;
	}

	if (
		not $self->authenticate(
			$self->current_user->{name},
			$self->param('oldpw')
		)
	  )
	{
		$self->render( 'change_password', invalid => 'password' );
		return;
	}

	$self->users->set_password(
		uid      => $self->current_user->{id},
		password => $password
	);

	$self->flash( success => 'password' );
	$self->redirect_to('account');
	$self->send_password_notification_mail();
}

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

	if ( $self->param('action') and $self->param('action') eq 'initiate' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}

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

		my $uid = $self->users->get_uid_by_name_and_mail(
			name  => $name,
			email => $email
		);

		if ( not $uid ) {
			$self->render( 'recover_password',
				invalid => 'recovery credentials' );
			return;
		}

		my $token = make_token();
		my $db    = $self->pg->db;
		my $tx    = $db->begin;

		my $error = $self->users->mark_for_password_reset(
			db    => $db,
			uid   => $uid,
			token => $token
		);

		if ($error) {
			$self->render( 'recover_password', invalid => $error );
			return;
		}

		my $success = $self->send_lostpassword_confirmation_mail(
			email => $email,
			name  => $name,
			uid   => $uid,
			token => $token
		);

		if ($success) {
			$tx->commit;
			$self->render( 'recover_password', success => 1 );
		}
		else {
			$self->render( 'recover_password', invalid => 'sendmail' );
		}
	}
	elsif ( $self->param('action')
		and $self->param('action') eq 'set_password' )
	{
		my $id        = $self->param('id');
		my $token     = $self->param('token');
		my $password  = $self->param('newpw');
		my $password2 = $self->param('newpw2');

		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render(
				'bad_request',
				csrf   => 1,
				status => 400
			);
			return;
		}
		if (
			not $self->users->verify_password_token(
				uid   => $id,
				token => $token
			)
		  )
		{
			$self->render( 'recover_password', invalid => 'change token' );
			return;
		}
		if ( $password ne $password2 ) {
			$self->render( 'set_password', invalid => 'password_notequal' );
			return;
		}

		if ( length($password) < 8 ) {
			$self->render( 'set_password', invalid => 'password_short' );
			return;
		}

		$self->users->set_password(
			uid      => $id,
			password => $password
		);

		my $account = $self->get_user_data($id);

		if ( not $self->authenticate( $account->{name}, $password ) ) {
			$self->render( 'set_password',
				invalid => 'Authentication failure – WTF?' );
		}

		$self->flash( success => 'password' );
		$self->redirect_to('account');

		$self->users->remove_password_token(
			uid   => $id,
			token => $token
		);

		$self->send_lostpassword_notification_mail($account);
	}
	else {
		$self->render('recover_password');
	}
}

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

	my $id    = $self->stash('id');
	my $token = $self->stash('token');

	if ( not $id =~ m{ ^ \d+ $ }x or $id > 2147483647 ) {
		$self->render( 'recover_password', invalid => 'recovery token' );
		return;
	}

	if (
		$self->users->verify_password_token(
			uid   => $id,
			token => $token
		)
	  )
	{
		$self->render('set_password');
	}
	else {
		$self->render( 'recover_password', invalid => 'recovery token' );
	}
}

sub confirm_mail {
	my ($self) = @_;
	my $id     = $self->current_user->{id};
	my $token  = $self->stash('token');

	# Some mail clients include the trailing ">" from the confirmation mail
	# when opening/copying the confirmation link. A token will never contain
	# this symbol, so remove it just in case.
	$token =~ s{>}{};

	# I did not yet find a mail client that also includes the trailing ",",
	# but you never now...
	$token =~ s{,}{};

	if (
		$self->users->change_mail_with_token(
			uid   => $id,
			token => $token
		)
	  )
	{
		$self->flash( success => 'mail' );
		$self->redirect_to('account');
	}
	else {
		$self->render( 'change_mail', invalid => 'change token' );
	}
}

sub account {
	my ($self)             = @_;
	my $uid                = $self->current_user->{id};
	my $rx_follow_requests = $self->users->has_follow_requests( uid => $uid );
	my $tx_follow_requests = $self->users->has_follow_requests(
		uid  => $uid,
		sent => 1
	);
	my $followers = $self->users->has_followers( uid => $uid );
	my $following = $self->users->has_followees( uid => $uid );
	my $blocked   = $self->users->has_blocked_users( uid => $uid );

	$self->render(
		'account',
		api_token              => $self->users->get_api_token( uid => $uid ),
		num_rx_follow_requests => $rx_follow_requests,
		num_tx_follow_requests => $tx_follow_requests,
		num_followers          => $followers,
		num_following          => $following,
		num_blocked            => $blocked,
	);
	$self->users->mark_seen( uid => $uid );
}

sub json_export {
	my ($self) = @_;
	my $uid = $self->current_user->{id};

	my $db = $self->pg->db;

	$self->render(
		json => {
			account    => $db->select( 'users', '*', { id => $uid } )->hash,
			in_transit => [
				$db->select( 'in_transit_str', '*', { user_id => $uid } )
				  ->hashes->each
			],
			journeys => [
				$db->select( 'journeys_str', '*', { user_id => $uid } )
				  ->hashes->each
			],
		}
	);
}

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

	my $resource = $self->param('resource');

	if ( not $resource ) {
		$self->render( 'not_found', status => 404 );
		return;
	}

	my $root_url = $self->base_url_for('/')->to_abs->host;

	if (   not $root_url
		or not $resource
		=~ m{ ^ acct: [@]? (?<name> [^@]+ ) [@] $root_url $ }x )
	{
		$self->render( 'not_found', status => 404 );
		return;
	}

	my $name = $+{name};
	my $user = $self->users->get_privacy_by( name => $name );

	if ( not $user ) {
		$self->render( 'not_found', status => 404 );
		return;
	}

	my $profile_url
	  = $self->base_url_for("/p/${name}")->to_abs->scheme('https')->to_string;

	$self->render(
		text => JSON->new->encode(
			{
				subject => $resource,
				aliases => [ $profile_url, ],
				links   => [
					{
						rel  => 'http://webfinger.net/rel/profile-page',
						type => 'text/html',
						href => $profile_url,
					},
				],
			}
		),
		format => 'json',
	);
}

1;
