package Travelynx;

# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later

use Mojo::Base 'Mojolicious';

use Mojo::Pg;
use Mojo::Promise;
use Mojolicious::Plugin::Authentication;
use Cache::File;
use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
use DateTime::Format::Strptime;
use Encode      qw(decode encode);
use File::Slurp qw(read_file);
use JSON;
use List::Util;
use List::UtilsBy   qw(uniq_by);
use List::MoreUtils qw(first_index);
use Travel::Status::DE::DBWagenreihung;
use Travelynx::Helper::DBDB;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
use Travelynx::Model::Journeys;
use Travelynx::Model::JourneyStatsCache;
use Travelynx::Model::Stations;
use Travelynx::Model::Traewelling;
use Travelynx::Model::Users;

sub check_password {
	my ( $password, $hash ) = @_;

	if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) {
		return 1;
	}
	return 0;
}

sub epoch_to_dt {
	my ($epoch) = @_;

	# Bugs (and user errors) may lead to undefined timestamps. Set them to
	# 1970-01-01 to avoid crashing and show obviously wrong data instead.
	$epoch //= 0;

	return DateTime->from_epoch(
		epoch     => $epoch,
		time_zone => 'Europe/Berlin',
		locale    => 'de-DE',
	);
}

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

	push( @{ $self->commands->namespaces }, 'Travelynx::Command' );

	$self->defaults( layout => 'default' );

	$self->types->type( csv  => 'text/csv; charset=utf-8' );
	$self->types->type( json => 'application/json; charset=utf-8' );

	$self->plugin('Config');

	if ( $self->config->{secrets} ) {
		$self->secrets( $self->config->{secrets} );
	}

	chomp $self->config->{version};
	$self->defaults( version => $self->config->{version} // 'UNKNOWN' );

	$self->plugin(
		authentication => {
			autoload_user => 1,
			fail_render   => { template => 'login' },
			load_user     => sub {
				my ( $self, $uid ) = @_;
				return $self->get_user_data($uid);
			},
			validate_user => sub {
				my ( $self, $username, $password, $extradata ) = @_;
				my $user_info
				  = $self->users->get_login_data( name => $username );
				if ( not $user_info ) {
					return undef;
				}
				if ( $user_info->{status} != 1 ) {
					return undef;
				}
				if ( check_password( $password, $user_info->{password_hash} ) )
				{
					return $user_info->{id};
				}
				return undef;
			},
		}
	);

	if ( my $oa = $self->config->{traewelling}{oauth} ) {
		$self->plugin(
			OAuth2 => {
				providers => {
					traewelling => {
						key           => $oa->{id},
						secret        => $oa->{secret},
						authorize_url =>
'https://traewelling.de/oauth/authorize?response_type=code',
						token_url => 'https://traewelling.de/oauth/token',
					}
				}
			}
		);
	}

	$self->sessions->default_expiration( 60 * 60 * 24 * 180 );

	# Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default.
	# In theory, "The default lax value provides a reasonable balance between
	# security and usability for websites that want to maintain user's logged-in
	# session after the user arrives from an external link". In practice,
	# Safari (both iOS and macOS) does not send a SameSite=lax cookie when
	# following a link from an external site. So, bahn.expert providing a
	# checkin link to travelynx.de/s/whatever does not work because the user
	# is not logged in due to Safari not sending the cookie.
	#
	# This looks a lot like a Safari bug, but we can't do anything about it. So
	# we don't set the SameSite flag at all for now.
	#
	# --derf, 2019-05-01
	$self->sessions->samesite(undef);

	$self->defaults( layout => 'default' );

	$self->hook(
		before_dispatch => sub {
			my ($self) = @_;

			# The "theme" cookie is set client-side if the theme we delivered was
			# changed by dark mode detection or by using the theme switcher. It's
			# not part of Mojolicious' session data (and can't be, due to
			# signing and HTTPOnly), so we need to add it here.
			for my $cookie ( @{ $self->req->cookies } ) {
				if ( $cookie->name eq 'theme' ) {
					$self->session( theme => $cookie->value );
					return;
				}
			}
		}
	);

	$self->attr(
		cache_iris_main => sub {
			my ($self) = @_;

			return Cache::File->new(
				cache_root      => $self->app->config->{cache}->{schedule},
				default_expires => '6 hours',
				lock_level      => Cache::File::LOCK_LOCAL(),
			);
		}
	);

	$self->attr(
		cache_iris_rt => sub {
			my ($self) = @_;

			return Cache::File->new(
				cache_root      => $self->app->config->{cache}->{realtime},
				default_expires => '70 seconds',
				lock_level      => Cache::File::LOCK_LOCAL(),
			);
		}
	);

	# https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden
	# via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts
	$self->attr(
		ice_name => sub {
			state $id_to_name = {
				Travel::Status::DE::DBWagenreihung::Group::name_to_designation(
				)
			};
			return $id_to_name;
		}
	);

	$self->attr(
		renamed_station => sub {
			my $legacy_to_new = JSON->new->utf8->decode(
				scalar read_file('share/old_station_names.json') );
			return $legacy_to_new;
		}
	);

	if ( not $self->app->config->{base_url} ) {
		$self->app->log->error(
"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation."
		);
	}

	$self->helper(
		base_url_for => sub {
			my ( $self, $path ) = @_;
			if ( ( my $url = $self->url_for($path) )->base ne q{}
				or not $self->app->config->{base_url} )
			{
				return $url;
			}
			return $self->url_for($path)
			  ->base( $self->app->config->{base_url} );
		}
	);

	$self->helper(
		hafas => sub {
			my ($self) = @_;
			state $hafas = Travelynx::Helper::HAFAS->new(
				log            => $self->app->log,
				service_config => $self->app->config->{hafas},
				main_cache     => $self->app->cache_iris_main,
				realtime_cache => $self->app->cache_iris_rt,
				root_url       => $self->base_url_for('/')->to_abs,
				user_agent     => $self->ua,
				version        => $self->app->config->{version},
			);
		}
	);

	$self->helper(
		iris => sub {
			my ($self) = @_;
			state $iris = Travelynx::Helper::IRIS->new(
				log            => $self->app->log,
				main_cache     => $self->app->cache_iris_main,
				realtime_cache => $self->app->cache_iris_rt,
				root_url       => $self->base_url_for('/')->to_abs,
				version        => $self->app->config->{version},
			);
		}
	);

	$self->helper(
		traewelling => sub {
			my ($self) = @_;
			state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
		}
	);

	$self->helper(
		traewelling_api => sub {
			my ($self) = @_;
			state $trwl_api = Travelynx::Helper::Traewelling->new(
				log        => $self->app->log,
				model      => $self->traewelling,
				root_url   => $self->base_url_for('/')->to_abs,
				user_agent => $self->ua,
				version    => $self->app->config->{version},
			);
		}
	);

	$self->helper(
		in_transit => sub {
			my ($self) = @_;
			state $in_transit = Travelynx::Model::InTransit->new(
				log => $self->app->log,
				pg  => $self->pg,
			);
		}
	);

	$self->helper(
		journey_stats_cache => sub {
			my ($self) = @_;
			state $journey_stats_cache
			  = Travelynx::Model::JourneyStatsCache->new(
				log => $self->app->log,
				pg  => $self->pg,
			  );
		}
	);

	$self->helper(
		journeys => sub {
			my ($self) = @_;
			state $journeys = Travelynx::Model::Journeys->new(
				log             => $self->app->log,
				pg              => $self->pg,
				in_transit      => $self->in_transit,
				stats_cache     => $self->journey_stats_cache,
				renamed_station => $self->app->renamed_station,
				stations        => $self->stations,
			);
		}
	);

	$self->helper(
		pg => sub {
			my ($self) = @_;
			my $config = $self->app->config;

			my $dbname = $config->{db}->{database};
			my $host   = $config->{db}->{host} // 'localhost';
			my $port   = $config->{db}->{port} // 5432;
			my $user   = $config->{db}->{user};
			my $pw     = $config->{db}->{password};

			state $pg
			  = Mojo::Pg->new("postgresql://${user}\@${host}:${port}/${dbname}")
			  ->password($pw);

			$pg->on(
				connection => sub {
					my ( $pg, $dbh ) = @_;
					$dbh->do("set time zone 'Europe/Berlin'");
				}
			);

			return $pg;
		}
	);

	$self->helper(
		sendmail => sub {
			state $sendmail = Travelynx::Helper::Sendmail->new(
				config => ( $self->config->{mail} // {} ),
				log    => $self->log
			);
		}
	);

	$self->helper(
		stations => sub {
			my ($self) = @_;
			state $stations
			  = Travelynx::Model::Stations->new( pg => $self->pg );
		}
	);

	$self->helper(
		users => sub {
			my ($self) = @_;
			state $users = Travelynx::Model::Users->new( pg => $self->pg );
		}
	);

	$self->helper(
		dbdb => sub {
			my ($self) = @_;
			state $dbdb = Travelynx::Helper::DBDB->new(
				log            => $self->app->log,
				main_cache     => $self->app->cache_iris_main,
				realtime_cache => $self->app->cache_iris_rt,
				root_url       => $self->base_url_for('/')->to_abs,
				user_agent     => $self->ua,
				version        => $self->app->config->{version},
			);
		}
	);

	$self->helper(
		'now' => sub {
			return DateTime->now( time_zone => 'Europe/Berlin' );
		}
	);

	$self->helper(
		'numify_skipped_stations' => sub {
			my ( $self, $count ) = @_;

			if ( $count == 0 ) {
				return 'INTERNAL ERROR';
			}
			if ( $count == 1 ) {
				return
'Eine Station ohne Geokoordinaten wurde nicht berücksichtigt.';
			}
			return
"${count} Stationen ohne Geookordinaten wurden nicht berücksichtigt.";
		}
	);

	$self->helper(
		'sprintf_km' => sub {
			my ( $self, $km ) = @_;

			if ( $km < 1 ) {
				return sprintf( '%.f m', $km * 1000 );
			}
			if ( $km < 10 ) {
				return sprintf( '%.1f km', $km );
			}
			return sprintf( '%.f km', $km );
		}
	);

	$self->helper(
		'load_icon' => sub {
			my ( $self, $load ) = @_;
			my $first  = $load->{FIRST}  // 0;
			my $second = $load->{SECOND} // 0;

			my @symbols
			  = (
				qw(help_outline person_outline people priority_high not_interested)
			  );

			return ( $symbols[$first], $symbols[$second] );
		}
	);

	$self->helper(
		'visibility_icon' => sub {
			my ( $self, $visibility ) = @_;
			if ( $visibility eq 'public' ) {
				return 'language';
			}
			if ( $visibility eq 'travelynx' ) {
				return 'lock_open';
			}
			if ( $visibility eq 'followers' ) {
				return 'group';
			}
			if ( $visibility eq 'unlisted' ) {
				return 'lock_outline';
			}
			if ( $visibility eq 'private' ) {
				return 'lock';
			}
			return 'help_outline';
		}
	);

	$self->helper(
		'checkin_p' => sub {
			my ( $self, %opt ) = @_;

			my $station  = $opt{station};
			my $train_id = $opt{train_id};
			my $ts       = $opt{ts};
			my $uid      = $opt{uid} // $self->current_user->{id};
			my $db       = $opt{db}  // $self->pg->db;
			my $hafas;

			my $user = $self->get_user_status( $uid, $db );
			if ( $user->{checked_in} or $user->{cancelled} ) {
				return Mojo::Promise->reject('You are already checked in');
			}

			if ( $opt{hafas} ) {
				return $self->_checkin_hafas_p(%opt);
			}

			my $promise = Mojo::Promise->new;

			$self->iris->get_departures_p(
				station    => $station,
				lookbehind => 140,
				lookahead  => 40
			)->then(
				sub {
					my ($status) = @_;

					if ( $status->{errstr} ) {
						$promise->reject( $status->{errstr} );
						return;
					}

					my $eva   = $status->{station_eva};
					my $train = List::Util::first { $_->train_id eq $train_id }
					@{ $status->{results} };

					if ( not defined $train ) {
						$promise->reject("Train ${train_id} not found");
						return;
					}

					eval {
						$self->in_transit->add(
							uid           => $uid,
							db            => $db,
							departure_eva => $eva,
							train         => $train,
							route      => [ $self->iris->route_diff($train) ],
							backend_id =>
							  $self->stations->get_backend_id( iris => 1 ),
						);
					};
					if ($@) {
						$self->app->log->error(
							"Checkin($uid): INSERT failed: $@");
						$promise->reject( 'INSERT failed: ' . $@ );
						return;
					}

					# mustn't be called during a transaction
					if ( not $opt{in_transaction} ) {
						$self->add_route_timestamps( $uid, $train, 1 );
						$self->add_wagonorder(
							uid          => $uid,
							train_id     => $train->train_id,
							is_departure => 1,
							eva          => $eva,
							datetime     => $train->sched_departure,
							train_type   => $train->type,
							train_no     => $train->train_no
						);
						$self->add_stationinfo( $uid, 1, $train->train_id,
							$eva );
						$self->run_hook( $uid, 'checkin' );
					}

					$promise->resolve($train);
					return;
				}
			)->catch(
				sub {
					my ( $err, $status ) = @_;
					$promise->reject( $status->{errstr} );
					return;
				}
			)->wait;

			return $promise;
		}
	);

	$self->helper(
		'_checkin_hafas_p' => sub {
			my ( $self, %opt ) = @_;

			my $station  = $opt{station};
			my $train_id = $opt{train_id};
			my $ts       = $opt{ts};
			my $uid      = $opt{uid} // $self->current_user->{id};
			my $db       = $opt{db}  // $self->pg->db;
			my $hafas;

			my $promise = Mojo::Promise->new;

			$self->hafas->get_journey_p(
				service       => $opt{hafas},
				trip_id       => $train_id,
				with_polyline => 1
			)->then(
				sub {
					my ($journey) = @_;
					my $found;
					for my $stop ( $journey->route ) {
						if (   $stop->loc->name eq $station
							or $stop->loc->eva == $station )
						{
							$found = $stop;

							# Lines may serve the same stop several times.
							# Keep looking until the scheduled departure
							# matches the one passed while checking in.
							if ( $ts and $stop->sched_dep->epoch == $ts ) {
								last;
							}
						}
					}
					if ( not $found ) {
						$promise->reject(
"Did not find stop '$station' within journey '$train_id'"
						);
						return;
					}
					for my $stop ( $journey->route ) {
						$self->stations->add_or_update(
							stop  => $stop,
							db    => $db,
							hafas => $opt{hafas},
						);
					}
					eval {
						$self->in_transit->add(
							uid        => $uid,
							db         => $db,
							journey    => $journey,
							stop       => $found,
							data       => { trip_id => $journey->id },
							backend_id => $self->stations->get_backend_id(
								hafas => $opt{hafas}
							),
						);
					};
					if ($@) {
						$self->app->log->error(
							"Checkin($uid): INSERT failed: $@");
						$promise->reject( 'INSERT failed: ' . $@ );
						return;
					}

					my $polyline;
					if ( $journey->polyline ) {
						my @station_list;
						my @coordinate_list;
						for my $coord ( $journey->polyline ) {
							if ( $coord->{name} ) {
								push(
									@coordinate_list,
									[
										$coord->{lon}, $coord->{lat},
										$coord->{eva}
									]
								);
								push( @station_list, $coord->{name} );
							}
							else {
								push( @coordinate_list,
									[ $coord->{lon}, $coord->{lat} ] );
							}
						}

						# equal length → polyline only consists of straight
						# lines between stops. that's not helpful.
						if ( @station_list == @coordinate_list ) {
							$self->log->debug( 'Ignoring polyline for '
								  . $journey->line
								  . ' as it only consists of straight lines between stops.'
							);
						}
						else {
							$polyline = {
								from_eva => ( $journey->route )[0]->loc->eva,
								to_eva   => ( $journey->route )[-1]->loc->eva,
								coords   => \@coordinate_list,
							};
						}
					}

					if ($polyline) {
						$self->in_transit->set_polyline(
							uid      => $uid,
							db       => $db,
							polyline => $polyline,
						);
					}

					# mustn't be called during a transaction
					if ( not $opt{in_transaction} ) {
						$self->run_hook( $uid, 'checkin' );
						if ( $opt{hafas} eq 'DB' and $journey->class <= 16 ) {
							$self->add_wagonorder(
								uid          => $uid,
								train_id     => $journey->id,
								is_departure => 1,
								eva          => $found->loc->eva,
								datetime     => $found->sched_dep,
								train_type   => $journey->type,
								train_no     => $journey->number
							);
							$self->add_stationinfo( $uid, 1, $journey->id,
								$found->loc->eva );
						}
					}

					$promise->resolve($journey);
				}
			)->catch(
				sub {
					my ($err) = @_;
					$promise->reject($err);
					return;
				}
			)->wait;

			return $promise;
		}
	);

	$self->helper(
		'undo' => sub {
			my ( $self, $journey_id, $uid ) = @_;
			$uid //= $self->current_user->{id};

			if ( $journey_id eq 'in_transit' ) {
				eval { $self->in_transit->delete( uid => $uid ); };
				if ($@) {
					$self->app->log->error("Undo($uid, $journey_id): $@");
					return "Undo($journey_id): $@";
				}
				$self->run_hook( $uid, 'undo' );
				return undef;
			}
			if ( $journey_id !~ m{ ^ \d+ $ }x ) {
				return 'Invalid Journey ID';
			}

			eval {
				my $db = $self->pg->db;
				my $tx = $db->begin;

				my $journey = $self->journeys->pop(
					uid        => $uid,
					db         => $db,
					journey_id => $journey_id
				);

				if ( $journey->{edited} ) {
					die(
"Cannot undo a journey which has already been edited. Please delete manually.\n"
					);
				}

				delete $journey->{edited};
				delete $journey->{id};

				# users may force checkouts at stations that are not part of
				# the train's scheduled (or real-time) route. re-adding those
				# to in-transit violates the assumption that each train has
				# a valid destination. Remove the target in this case.
				my $route = JSON->new->decode( $journey->{route} );
				my $found_checkout_id;
				for my $stop ( @{$route} ) {
					if ( $stop->[1] == $journey->{checkout_station_id} ) {
						$found_checkout_id = 1;
						last;
					}
				}
				if ( not $found_checkout_id ) {
					$journey->{checkout_station_id} = undef;
					$journey->{checkout_time}       = undef;
					$journey->{arr_platform}        = undef;
					$journey->{sched_arrival}       = undef;
					$journey->{real_arrival}        = undef;
				}

				$self->in_transit->add_from_journey(
					db      => $db,
					journey => $journey
				);

				my $cache_ts = DateTime->now( time_zone => 'Europe/Berlin' );
				if ( $journey->{real_departure}
					=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x )
				{
					$cache_ts->set(
						year  => $+{year},
						month => $+{month}
					);
				}

				$self->journey_stats_cache->invalidate(
					ts  => $cache_ts,
					db  => $db,
					uid => $uid
				);

				$tx->commit;
			};
			if ($@) {
				$self->app->log->error("Undo($uid, $journey_id): $@");
				return "Undo($journey_id): $@";
			}
			$self->run_hook( $uid, 'undo' );
			return undef;
		}
	);

	$self->helper(
		'checkout_p' => sub {
			my ( $self, %opt ) = @_;

			my $station      = $opt{station};
			my $dep_eva      = $opt{dep_eva};
			my $arr_eva      = $opt{arr_eva};
			my $with_related = $opt{with_related} // 0;
			my $force        = $opt{force};
			my $uid          = $opt{uid} // $self->current_user->{id};
			my $db           = $opt{db}  // $self->pg->db;
			my $user         = $self->get_user_status( $uid, $db );
			my $train_id     = $user->{train_id};
			my $hafas        = $opt{hafas};

			my $promise = Mojo::Promise->new;

			if ( not $station ) {
				$self->app->log->error("Checkout($uid): station is empty");
				return $promise->resolve( 1,
					'BUG: Checkout station is empty.' );
			}

			if ( not $user->{checked_in} and not $user->{cancelled} ) {
				return $promise->resolve( 0,
					'You are not checked into any train' );
			}

			if ( $dep_eva and $dep_eva != $user->{dep_eva} ) {
				return $promise->resolve( 0, 'race condition' );
			}
			if ( $arr_eva and $arr_eva != $user->{arr_eva} ) {
				return $promise->resolve( 0, 'race condition' );
			}

			if ( $user->{is_hafas} ) {
				return $self->_checkout_hafas_p(%opt);
			}

			my $now     = DateTime->now( time_zone => 'Europe/Berlin' );
			my $journey = $self->in_transit->get(
				uid       => $uid,
				with_data => 1
			);

			$self->iris->get_departures_p(
				station      => $station,
				lookbehind   => 120,
				lookahead    => 180,
				with_related => $with_related,
			)->then(
				sub {
					my ($status) = @_;

					my $new_checkout_station_id = $status->{station_eva};

					# Store the intended checkout station regardless of this operation's
					# success.
					# TODO for with_related == 1, the correct EVA may be different
					# and should be fetched from $train later on
					$self->in_transit->set_arrival_eva(
						uid         => $uid,
						db          => $db,
						arrival_eva => $new_checkout_station_id
					);

					# If in_transit already contains arrival data for another estimated
					# destination, we must invalidate it.
					if ( defined $journey->{checkout_station_id}
						and $journey->{checkout_station_id}
						!= $new_checkout_station_id )
					{
						$self->in_transit->unset_arrival_data(
							uid => $uid,
							db  => $db
						);
					}

					# Note that a train may pass the same station several times.
					# Notable example: S41 / S42 ("Ringbahn") both starts and
					# terminates at Berlin Südkreuz
					my $train = List::Util::first {
						$_->train_id eq $train_id
						  and $_->sched_arrival
						  and $_->sched_arrival->epoch
						  > $user->{sched_departure}->epoch
					}
					@{ $status->{results} };

					$train //= List::Util::first { $_->train_id eq $train_id }
					@{ $status->{results} };

					if ( not defined $train ) {

						# Arrival time via IRIS is unknown, so the train probably
						# has not arrived yet. Fall back to HAFAS.
						# TODO support cases where $station is EVA or DS100 code
						if (
							my $station_data
							= List::Util::first { $_->[0] eq $station }
							@{ $journey->{route} }
						  )
						{
							$station_data = $station_data->[2];
							if ( $station_data->{sched_arr} ) {
								my $sched_arr
								  = epoch_to_dt( $station_data->{sched_arr} );
								my $rt_arr
								  = epoch_to_dt( $station_data->{rt_arr} );
								if ( $rt_arr->epoch == 0 ) {
									$rt_arr = $sched_arr->clone;
									if (    $station_data->{arr_delay}
										and $station_data->{arr_delay}
										=~ m{^\d+$} )
									{
										$rt_arr->add( minutes =>
											  $station_data->{arr_delay} );
									}
								}
								$self->in_transit->set_arrival_times(
									uid           => $uid,
									db            => $db,
									sched_arrival => $sched_arr,
									rt_arrival    => $rt_arr
								);
							}
						}
						if ( not $force ) {

							# mustn't be called during a transaction
							if ( not $opt{in_transaction} ) {
								$self->run_hook( $uid, 'update' );
							}
							$promise->resolve( 1, undef );
							return;
						}
					}
					my $has_arrived = 0;

					eval {

						my $tx;
						if ( not $opt{in_transaction} ) {
							$tx = $db->begin;
						}

						if (    defined $train
							and not $train->arrival
							and not $force )
						{
							my $train_no = $train->train_no;
							die("Train ${train_no} has no arrival timestamp\n");
						}
						elsif ( defined $train and $train->arrival ) {
							$self->in_transit->set_arrival(
								uid   => $uid,
								db    => $db,
								train => $train,
							);

							$has_arrived
							  = $train->arrival->epoch < $now->epoch ? 1 : 0;
							if ($has_arrived) {
								my @unknown_stations
								  = $self->stations->grep_unknown(
									$train->route );
								if (@unknown_stations) {
									$self->app->log->warn(
										sprintf(
'Route of %s %s (%s -> %s) contains unknown stations: %s',
											$train->type,
											$train->train_no,
											$train->origin,
											$train->destination,
											join( ', ', @unknown_stations )
										)
									);
								}
							}
						}

						$journey = $self->in_transit->get(
							uid => $uid,
							db  => $db
						);

						if ( $has_arrived or $force ) {
							$self->journeys->add_from_in_transit(
								db      => $db,
								journey => $journey
							);
							$self->in_transit->delete(
								uid => $uid,
								db  => $db
							);

							my $cache_ts = $now->clone;
							if ( $journey->{real_departure}
								=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x
							  )
							{
								$cache_ts->set(
									year  => $+{year},
									month => $+{month}
								);
							}
							$self->journey_stats_cache->invalidate(
								ts  => $cache_ts,
								db  => $db,
								uid => $uid
							);
						}
						elsif ( defined $train
							and $train->arrival_is_cancelled )
						{

							# This branch is only taken if the deparure was not cancelled,
							# i.e., if the train was supposed to go here but got
							# redirected or cancelled on the way and not from the start on.
							# If the departure itself was cancelled, the user route is
							# cancelled_from action -> 'cancelled journey' panel on main page
							# -> cancelled_to action -> force checkout (causing the
							# previous branch to be taken due to $force)
							$journey->{cancelled} = 1;
							$self->journeys->add_from_in_transit(
								db      => $db,
								journey => $journey
							);
							$self->in_transit->set_cancelled_destination(
								uid                   => $uid,
								db                    => $db,
								cancelled_destination => $train->station,
							);
						}

						if ( not $opt{in_transaction} ) {
							$tx->commit;
						}
					};

					if ($@) {
						$self->app->log->error("Checkout($uid): $@");
						$promise->resolve( 1, 'Checkout error: ' . $@ );
						return;
					}

					if ( $has_arrived or $force ) {
						if ( not $opt{in_transaction} ) {
							$self->run_hook( $uid, 'checkout' );
						}
						$promise->resolve( 0, undef );
						return;
					}
					if ( not $opt{in_transaction} ) {
						$self->run_hook( $uid, 'update' );
						$self->add_route_timestamps( $uid, $train, 0, 1 );
						$self->add_wagonorder(
							uid        => $uid,
							train_id   => $train->train_id,
							is_arrival => 1,
							eva        => $new_checkout_station_id,
							datetime   => $train->sched_departure,
							train_type => $train->type,
							train_no   => $train->train_no
						);
						$self->add_stationinfo( $uid, 0, $train->train_id,
							$dep_eva, $new_checkout_station_id );
					}
					$promise->resolve( 1, undef );
					return;

				}
			)->catch(
				sub {
					my ($err) = @_;
					$promise->resolve( 1, $err );
					return;
				}
			)->wait;

			return $promise;
		}
	);

	$self->helper(
		'_checkout_hafas_p' => sub {
			my ( $self, %opt ) = @_;

			my $station = $opt{station};
			my $force   = $opt{force};
			my $uid     = $opt{uid} // $self->current_user->{id};
			my $db      = $opt{db}  // $self->pg->db;

			my $promise = Mojo::Promise->new;

			my $now     = DateTime->now( time_zone => 'Europe/Berlin' );
			my $journey = $self->in_transit->get(
				uid             => $uid,
				db              => $db,
				with_data       => 1,
				with_timestamps => 1,
				with_visibility => 1,
				postprocess     => 1,
			);

			# with_visibility needed due to postprocess

			my $found;
			my $has_arrived;
			for my $stop ( @{ $journey->{route_after} } ) {
				if ( $station eq $stop->[0] or $station eq $stop->[1] ) {
					$found = 1;
					$self->in_transit->set_arrival_eva(
						uid         => $uid,
						db          => $db,
						arrival_eva => $stop->[1],
					);
					if ( defined $journey->{checkout_station_id}
						and $journey->{checkout_station_id} != $stop->{eva} )
					{
						$self->in_transit->unset_arrival_data(
							uid => $uid,
							db  => $db
						);
					}
					$self->in_transit->set_arrival_times(
						uid           => $uid,
						db            => $db,
						sched_arrival => $stop->[2]{sched_arr},
						rt_arrival    =>
						  ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} )
					);
					if (
						$now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) )
					{
						$has_arrived = 1;
					}
					last;
				}
			}
			if ( not $found and not $force ) {
				return $promise->resolve( 1, 'station not found in route' );
			}

			eval {
				my $tx;
				if ( not $opt{in_transaction} ) {
					$tx = $db->begin;
				}

				if ( $has_arrived or $force ) {
					$journey = $self->in_transit->get(
						uid => $uid,
						db  => $db
					);
					$self->journeys->add_from_in_transit(
						db      => $db,
						journey => $journey
					);
					$self->in_transit->delete(
						uid => $uid,
						db  => $db
					);

					my $cache_ts = $now->clone;
					if ( $journey->{real_departure}
						=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x )
					{
						$cache_ts->set(
							year  => $+{year},
							month => $+{month}
						);
					}
					$self->journey_stats_cache->invalidate(
						ts  => $cache_ts,
						db  => $db,
						uid => $uid
					);
				}

				if ($tx) {
					$tx->commit;
				}
			};

			if ($@) {
				$self->app->log->error("Checkout($uid): $@");
				return $promise->resolve( 1, 'Checkout error: ' . $@ );
			}

			if ( $has_arrived or $force ) {
				if ( not $opt{in_transaction} ) {
					$self->run_hook( $uid, 'checkout' );
				}
				return $promise->resolve( 0, undef );
			}
			if ( not $opt{in_transaction} ) {
				$self->run_hook( $uid, 'update' );
			}
			return $promise->resolve( 1, undef );
		}
	);

	# This helper should only be called directly when also providing a user ID.
	# If you don't have one, use current_user() instead (get_user_data will
	# delegate to it anyways).
	$self->helper(
		'get_user_data' => sub {
			my ( $self, $uid ) = @_;

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

			return $self->users->get( uid => $uid );
		}
	);

	$self->helper(
		'run_hook' => sub {
			my ( $self, $uid, $reason, $callback ) = @_;

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

			if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x )
			{
				if ($callback) {
					&$callback();
				}
				return;
			}

			my $status    = $self->get_user_status_json_v1( uid => $uid );
			my $header    = {};
			my $hook_body = {
				reason => $reason,
				status => $status,
			};

			if ( $hook->{token} ) {
				$header->{Authorization} = "Bearer $hook->{token}";
				$header->{'User-Agent'}
				  = 'travelynx/' . $self->app->config->{version};
			}

			my $ua = $self->ua;
			if ($callback) {
				$ua->request_timeout(4);
			}
			else {
				$ua->request_timeout(10);
			}

			$ua->post_p( $hook->{url} => $header => json => $hook_body )->then(
				sub {
					my ($tx) = @_;
					if ( my $err = $tx->error ) {
						$self->users->update_webhook_status(
							uid     => $uid,
							url     => $hook->{url},
							success => 0,
							text    => "HTTP $err->{code} $err->{message}"
						);
					}
					else {
						$self->users->update_webhook_status(
							uid     => $uid,
							url     => $hook->{url},
							success => 1,
							text    => $tx->result->body
						);
					}
					if ($callback) {
						&$callback();
					}
					return;
				}
			)->catch(
				sub {
					my ($err) = @_;
					$self->users->update_webhook_status(
						uid     => $uid,
						url     => $hook->{url},
						success => 0,
						text    => $err
					);
					if ($callback) {
						&$callback();
					}
					return;
				}
			)->wait;
		}
	);

	$self->helper(
		'add_wagonorder' => sub {
			my ( $self, %opt ) = @_;

			my $uid        = $opt{uid};
			my $train_id   = $opt{train_id};
			my $train_type = $opt{train_type};
			my $train_no   = $opt{train_no};
			my $eva        = $opt{eva};
			my $datetime   = $opt{datetime};

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

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

			if ( $datetime and $train_no ) {
				$self->dbdb->has_wagonorder_p(%opt)->then(
					sub {
						return $self->dbdb->get_wagonorder_p(%opt);
					}
				)->then(
					sub {
						my ($wagonorder) = @_;

						my $data      = {};
						my $user_data = {};

						if ( $opt{is_departure}
							and not exists $wagonorder->{error} )
						{
							$data->{wagonorder_dep}   = $wagonorder;
							$user_data->{wagongroups} = [];
							for my $group ( @{ $wagonorder->{groups} // [] } ) {
								my @wagons;
								for my $wagon ( @{ $group->{vehicles} // [] } )
								{
									push(
										@wagons,
										{
											id     => $wagon->{vehicleID},
											number => $wagon
											  ->{wagonIdentificationNumber},
											type =>
											  $wagon->{type}{constructionType},
										}
									);
								}
								push(
									@{ $user_data->{wagongroups} },
									{
										name => $group->{name},
										to   => $group->{transport}{destination}
										  {name},
										type   => $group->{transport}{category},
										no     => $group->{transport}{number},
										wagons => [@wagons],
									}
								);
								if (    $group->{name}
									and $group->{name} eq 'ICE0304' )
								{
									$data->{wagonorder_pride} = 1;
								}
							}
							$self->in_transit->update_data(
								uid      => $uid,
								db       => $db,
								data     => $data,
								train_id => $train_id,
							);
							$self->in_transit->update_user_data(
								uid       => $uid,
								db        => $db,
								user_data => $user_data,
								train_id  => $train_id,
							);
						}
						elsif ( $opt{is_arrival}
							and not exists $wagonorder->{error} )
						{
							$data->{wagonorder_arr} = $wagonorder;
							$self->in_transit->update_data(
								uid      => $uid,
								db       => $db,
								data     => $data,
								train_id => $train_id,
							);
						}
						return;
					}
				)->catch(
					sub {
						# no wagonorder? no problem.
						return;
					}
				)->wait;
			}
		}
	);

	# This helper is only ever called from an IRIS context.
	# HAFAS already has all relevant information.
	$self->helper(
		'add_route_timestamps' => sub {
			my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_;

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

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

			# TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str
			# Here it's only needed because of dep_eva / arr_eva names
			my $in_transit = $self->in_transit->get(
				db              => $db,
				uid             => $uid,
				with_data       => 1,
				with_timestamps => 1
			);

			if ( not $in_transit ) {
				return;
			}

			my $route    = $in_transit->{route};
			my $train_id = $train->train_id;

			my $tripid_promise;

			if ( $in_transit->{data}{trip_id} ) {
				$tripid_promise
				  = Mojo::Promise->resolve( $in_transit->{data}{trip_id} );
			}
			else {
				$tripid_promise = $self->hafas->get_tripid_p( train => $train );
			}

			$tripid_promise->then(
				sub {
					my ($trip_id) = @_;

					if ( not $in_transit->{extra_data}{trip_id} ) {
						$self->in_transit->update_data(
							uid      => $uid,
							db       => $db,
							data     => { trip_id => $trip_id },
							train_id => $train_id,
						);
					}

					return $self->hafas->get_route_p(
						train         => $train,
						trip_id       => $trip_id,
						with_polyline => (
							$update_polyline
							  or not $in_transit->{polyline}
						) ? 1 : 0,
					);
				}
			)->then(
				sub {
					my ( $new_route, $journey, $polyline ) = @_;
					my $db_route;

					for my $stop ( $journey->route ) {
						$self->stations->add_or_update(
							stop => $stop,
							db   => $db,
							iris => 1,
						);
					}

					for my $i ( 0 .. $#{$new_route} ) {
						my $old_name  = $route->[$i][0];
						my $old_eva   = $route->[$i][1];
						my $old_entry = $route->[$i][2];
						my $new_name  = $new_route->[$i]->{name};
						my $new_eva   = $new_route->[$i]->{eva};
						my $new_entry = $new_route->[$i];

						if ( defined $old_name and $old_name eq $new_name ) {
							if ( $old_entry->{rt_arr}
								and not $new_entry->{rt_arr} )
							{
								$new_entry->{rt_arr} = $old_entry->{rt_arr};
								$new_entry->{arr_delay}
								  = $old_entry->{arr_delay};
							}
							if ( $old_entry->{rt_dep}
								and not $new_entry->{rt_dep} )
							{
								$new_entry->{rt_dep} = $old_entry->{rt_dep};
								$new_entry->{dep_delay}
								  = $old_entry->{dep_delay};
							}
						}

						push(
							@{$db_route},
							[
								$new_name,
								$new_eva,
								{
									sched_arr    => $new_entry->{sched_arr},
									rt_arr       => $new_entry->{rt_arr},
									arr_delay    => $new_entry->{arr_delay},
									sched_dep    => $new_entry->{sched_dep},
									rt_dep       => $new_entry->{rt_dep},
									dep_delay    => $new_entry->{dep_delay},
									tz_offset    => $new_entry->{tz_offset},
									isAdditional => $new_entry->{isAdditional},
									isCancelled  => $new_entry->{isCancelled},
									load         => $new_entry->{load},
									lat          => $new_entry->{lat},
									lon          => $new_entry->{lon},
								}
							]
						);
					}

					my @messages;
					for my $m ( $journey->messages ) {
						if ( not $m->code ) {
							push(
								@messages,
								{
									header => $m->short,
									lead   => $m->text,
								}
							);
						}
					}

					$self->in_transit->set_route_data(
						uid            => $uid,
						db             => $db,
						route          => $db_route,
						delay_messages => [
							map { [ $_->[0]->epoch, $_->[1] ] }
							  $train->delay_messages
						],
						qos_messages => [
							map { [ $_->[0]->epoch, $_->[1] ] }
							  $train->qos_messages
						],
						him_messages => \@messages,
						train_id     => $train_id,
					);

					if ($polyline) {
						$self->in_transit->set_polyline(
							uid      => $uid,
							db       => $db,
							polyline => $polyline,
							old_id   => $in_transit->{polyline_id},
							train_id => $train_id,
						);
					}

					return;
				}
			)->catch(
				sub {
					my ($err) = @_;
					$self->app->log->debug("add_route_timestamps: $err");
					return;
				}
			)->wait;
		}
	);

	$self->helper(
		'add_stationinfo' => sub {
			my ( $self, $uid, $is_departure, $train_id, $dep_eva, $arr_eva )
			  = @_;

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

			my $db = $self->pg->db;
			if ($is_departure) {
				$self->dbdb->get_stationinfo_p($dep_eva)->then(
					sub {
						my ($station_info) = @_;
						my $data = { stationinfo_dep => $station_info };

						$self->in_transit->update_data(
							uid      => $uid,
							db       => $db,
							data     => $data,
							train_id => $train_id,
						);
						return;
					}
				)->catch(
					sub {
						# no stationinfo? no problem.
						return;
					}
				)->wait;
			}

			if ( $arr_eva and not $is_departure ) {
				$self->dbdb->get_stationinfo_p($arr_eva)->then(
					sub {
						my ($station_info) = @_;
						my $data = { stationinfo_arr => $station_info };

						$self->in_transit->update_data(
							uid      => $uid,
							db       => $db,
							data     => $data,
							train_id => $train_id,
						);
						return;
					}
				)->catch(
					sub {
						# no stationinfo? no problem.
						return;
					}
				)->wait;
			}
		}
	);

	$self->helper(
		'resolve_sb_template' => sub {
			my ( $self, $template, %opt ) = @_;
			my $ret  = $template;
			my $name = $opt{name} =~ s{/}{%2F}gr;
			$ret =~ s{[{]eva[}]}{$opt{eva}}g;
			$ret =~ s{[{]name[}]}{$name}g;
			$ret =~ s{[{]tt[}]}{$opt{tt}}g;
			$ret =~ s{[{]tn[}]}{$opt{tn}}g;
			$ret =~ s{[{]id[}]}{$opt{id}}g;
			$ret =~ s{[{]hafas[}]}{$opt{hafas}}g;
			return $ret;
		}
	);

	$self->helper(
		'stationinfo_to_direction' => sub {
			my ( $self, $platform_info, $wagonorder, $prev_stop, $next_stop )
			  = @_;
			if ( $platform_info->{kopfgleis} ) {
				if ($next_stop) {
					return $platform_info->{direction} eq 'r' ? 'l' : 'r';
				}
				return $platform_info->{direction};
			}
			elsif ( $prev_stop
				and exists $platform_info->{direction_from}{$prev_stop} )
			{
				return $platform_info->{direction_from}{$prev_stop};
			}
			elsif ( $next_stop
				and exists $platform_info->{direction_from}{$next_stop} )
			{
				return $platform_info->{direction_from}{$next_stop} eq 'r'
				  ? 'l'
				  : 'r';
			}
			elsif ($wagonorder) {
				my $wr;
				eval {
					$wr
					  = Travel::Status::DE::DBWagenreihung->new(
						from_json => $wagonorder );
				};
				if (    $wr
					and $wr->sectors
					and defined $wr->direction )
				{
					my $section_0 = ( $wr->sectors )[0];
					my $direction = $wr->direction;
					if (    $section_0->name eq 'A'
						and $direction == 0 )
					{
						return $platform_info->{direction};
					}
					elsif ( $section_0->name ne 'A'
						and $direction == 100 )
					{
						return $platform_info->{direction};
					}
					elsif ( $platform_info->{direction} ) {
						return $platform_info->{direction} eq 'r'
						  ? 'l'
						  : 'r';
					}
					return;
				}
			}
		}
	);

	$self->helper(
		'journey_to_ajax_route' => sub {
			my ( $self, $journey ) = @_;

			my @route;

			for my $station ( @{ $journey->{route_after} } ) {
				my $station_desc = $station->[0];

				my $sa = $station->[2]{sched_arr};
				my $ra = $station->[2]{rt_arr} || $station->[2]{sched_arr};
				my $sd = $station->[2]{sched_dep};
				my $rd = $station->[2]{rt_dep} || $station->[2]{sched_dep};

				$station_desc .= $sa ? $sa->strftime(';%s') : ';0';
				$station_desc .= $ra ? $ra->strftime(';%s') : ';0';
				$station_desc .= $sd ? $sd->strftime(';%s') : ';0';
				$station_desc .= $rd ? $rd->strftime(';%s') : ';0';

				push( @route, $station_desc );
			}

			return join( '|', @route );
		}
	);

	$self->helper(
		'get_user_status' => sub {
			my ( $self, $uid, $db ) = @_;

			$uid //= $self->current_user->{id};
			$db  //= $self->pg->db;

			my $now   = DateTime->now( time_zone => 'Europe/Berlin' );
			my $epoch = $now->epoch;

			my $in_transit = $self->in_transit->get(
				uid             => $uid,
				db              => $db,
				with_data       => 1,
				with_timestamps => 1,
				with_visibility => 1,
				postprocess     => 1,
			);

			if ($in_transit) {
				my $ret = $in_transit;

				my $traewelling = $self->traewelling->get(
					uid => $uid,
					db  => $db
				);
				if ( $traewelling->{latest_run}
					>= epoch_to_dt( $in_transit->{checkin_ts} ) )
				{
					$ret->{traewelling} = $traewelling;
					if ( @{ $traewelling->{data}{log} // [] }
						and ( my $log_entry = $traewelling->{data}{log}[0] ) )
					{
						if ( $log_entry->[2] ) {
							$ret->{traewelling_status} = $log_entry->[2];
							$ret->{traewelling_url}
							  = 'https://traewelling.de/status/'
							  . $log_entry->[2];
						}
						$ret->{traewelling_log_latest} = $log_entry->[1];
					}
				}

				my $stop_after_dep
				  = scalar @{ $ret->{route_after} }
				  ? $ret->{route_after}[0][0]
				  : undef;
				my $stop_before_dest;
				for my $i ( 1 .. $#{ $ret->{route_after} } ) {
					if (    $ret->{arr_name}
						and $ret->{route_after}[$i][0] eq $ret->{arr_name} )
					{
						$stop_before_dest = $ret->{route_after}[ $i - 1 ][0];
						last;
					}
				}

				my ($dep_platform_number)
				  = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} );
				if ( $dep_platform_number
					and
					exists $ret->{data}{stationinfo_dep}{$dep_platform_number} )
				{
					$ret->{dep_direction} = $self->stationinfo_to_direction(
						$ret->{data}{stationinfo_dep}{$dep_platform_number},
						$ret->{data}{wagonorder_dep},
						undef, $stop_after_dep
					);
				}

				my ($arr_platform_number)
				  = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} );
				if ( $arr_platform_number
					and
					exists $ret->{data}{stationinfo_arr}{$arr_platform_number} )
				{
					$ret->{arr_direction} = $self->stationinfo_to_direction(
						$ret->{data}{stationinfo_arr}{$arr_platform_number},
						$ret->{data}{wagonorder_arr},
						$stop_before_dest,
						undef
					);
				}

				if (    $ret->{departure_countdown} > 0
					and $in_transit->{data}{wagonorder_dep} )
				{
					my $wr;
					eval {
						$wr
						  = Travel::Status::DE::DBWagenreihung->new(
							from_json => $in_transit->{data}{wagonorder_dep} );
					};
					if (    $wr
						and $wr->carriages
						and defined $wr->direction )
					{
						$ret->{wagonorder} = $wr;
					}
				}

				return $ret;
			}

			my ( $latest, $latest_cancellation ) = $self->journeys->get_latest(
				uid => $uid,
				db  => $db,
			);

			if ( $latest_cancellation and $latest_cancellation->{cancelled} ) {
				if (
					my $station = $self->stations->get_by_eva(
						$latest_cancellation->{dep_eva},
						backend_id => $latest_cancellation->{backend_id},
					)
				  )
				{
					$latest_cancellation->{dep_ds100} = $station->{ds100};
					$latest_cancellation->{dep_name}  = $station->{name};
				}
				if (
					my $station = $self->stations->get_by_eva(
						$latest_cancellation->{arr_eva},
						backend_id => $latest_cancellation->{backend_id},
					)
				  )
				{
					$latest_cancellation->{arr_ds100} = $station->{ds100};
					$latest_cancellation->{arr_name}  = $station->{name};
				}
			}
			else {
				$latest_cancellation = undef;
			}

			if ($latest) {
				my $ts          = $latest->{checkout_ts};
				my $action_time = epoch_to_dt($ts);
				if (
					my $station = $self->stations->get_by_eva(
						$latest->{dep_eva}, backend_id => $latest->{backend_id}
					)
				  )
				{
					$latest->{dep_ds100} = $station->{ds100};
					$latest->{dep_name}  = $station->{name};
				}
				if (
					my $station = $self->stations->get_by_eva(
						$latest->{arr_eva}, backend_id => $latest->{backend_id}
					)
				  )
				{
					$latest->{arr_ds100} = $station->{ds100};
					$latest->{arr_name}  = $station->{name};
				}
				return {
					checked_in      => 0,
					cancelled       => 0,
					cancellation    => $latest_cancellation,
					backend_id      => $latest->{backend_id},
					backend_name    => $latest->{backend_name},
					is_iris         => $latest->{is_iris},
					is_hafas        => $latest->{is_hafas},
					journey_id      => $latest->{journey_id},
					timestamp       => $action_time,
					timestamp_delta => $now->epoch - $action_time->epoch,
					train_type      => $latest->{train_type},
					train_line      => $latest->{train_line},
					train_no        => $latest->{train_no},
					train_id        => $latest->{train_id},
					sched_departure => epoch_to_dt( $latest->{sched_dep_ts} ),
					real_departure  => epoch_to_dt( $latest->{real_dep_ts} ),
					dep_ds100       => $latest->{dep_ds100},
					dep_eva         => $latest->{dep_eva},
					dep_name        => $latest->{dep_name},
					dep_lat         => $latest->{dep_lat},
					dep_lon         => $latest->{dep_lon},
					dep_platform    => $latest->{dep_platform},
					sched_arrival   => epoch_to_dt( $latest->{sched_arr_ts} ),
					real_arrival    => epoch_to_dt( $latest->{real_arr_ts} ),
					arr_ds100       => $latest->{arr_ds100},
					arr_eva         => $latest->{arr_eva},
					arr_name        => $latest->{arr_name},
					arr_lat         => $latest->{arr_lat},
					arr_lon         => $latest->{arr_lon},
					arr_platform    => $latest->{arr_platform},
					comment         => $latest->{user_data}{comment},
					visibility      => $latest->{visibility},
					visibility_str  => $latest->{visibility_str},
					effective_visibility     => $latest->{effective_visibility},
					effective_visibility_str =>
					  $latest->{effective_visibility_str},
				};
			}

			return {
				checked_in      => 0,
				cancelled       => 0,
				cancellation    => $latest_cancellation,
				no_journeys_yet => 1,
				timestamp       => epoch_to_dt(0),
				timestamp_delta => $now->epoch,
			};
		}
	);

	$self->helper(
		'get_user_status_json_v1' => sub {
			my ( $self, %opt ) = @_;
			my $uid     = $opt{uid};
			my $privacy = $opt{privacy}
			  // $self->users->get_privacy_by( uid => $uid );
			my $status = $opt{status} // $self->get_user_status($uid);

			my $ret = {
				deprecated => \0,
				checkedIn  => (
					     $status->{checked_in}
					  or $status->{cancelled}
				) ? \1 : \0,
				comment => $status->{comment},
				backend => {
					id   => $status->{backend_id},
					type => $status->{is_hafas} ? 'HAFAS' : 'IRIS-TTS',
					name => $status->{backend_name},
				},
				fromStation => {
					ds100         => $status->{dep_ds100},
					name          => $status->{dep_name},
					uic           => $status->{dep_eva},
					longitude     => $status->{dep_lon},
					latitude      => $status->{dep_lat},
					platform      => $status->{dep_platform},
					scheduledTime => $status->{sched_departure}
					? $status->{sched_departure}->epoch
					: undef,
					realTime => $status->{real_departure}
					? $status->{real_departure}->epoch
					: undef,
				},
				toStation => {
					ds100         => $status->{arr_ds100},
					name          => $status->{arr_name},
					uic           => $status->{arr_eva},
					longitude     => $status->{arr_lon},
					latitude      => $status->{arr_lat},
					platform      => $status->{arr_platform},
					scheduledTime => $status->{sched_arrival}
					? $status->{sched_arrival}->epoch
					: undef,
					realTime => $status->{real_arrival}
					? $status->{real_arrival}->epoch
					: undef,
				},
				train => {
					type    => $status->{train_type},
					line    => $status->{train_line},
					no      => $status->{train_no},
					id      => $status->{train_id},
					hafasId => $status->{extra_data}{trip_id},
				},
				intermediateStops => [],
				visibility        => {
					level => $status->{effective_visibility},
					desc  => $status->{effective_visibility_str},
				}
			};

			if ( $opt{public} ) {
				if ( not $privacy->{comments_visible} ) {
					delete $ret->{comment};
				}
			}
			else {
				$ret->{actionTime}
				  = $status->{timestamp}
				  ? $status->{timestamp}->epoch
				  : undef;
			}

			for my $stop ( @{ $status->{route_after} // [] } ) {
				if ( $status->{arr_name} and $stop->[0] eq $status->{arr_name} )
				{
					last;
				}
				push(
					@{ $ret->{intermediateStops} },
					{
						name             => $stop->[0],
						scheduledArrival => $stop->[2]{sched_arr}
						? $stop->[2]{sched_arr}->epoch
						: undef,
						realArrival => $stop->[2]{rt_arr}
						? $stop->[2]{rt_arr}->epoch
						: undef,
						scheduledDeparture => $stop->[2]{sched_dep}
						? $stop->[2]{sched_dep}->epoch
						: undef,
						realDeparture => $stop->[2]{rt_dep}
						? $stop->[2]{rt_dep}->epoch
						: undef,
					}
				);
			}

			return $ret;
		}
	);

	$self->helper(
		'traewelling_to_travelynx_p' => sub {
			my ( $self, %opt ) = @_;
			my $traewelling = $opt{traewelling};
			my $user_data   = $opt{user_data};
			my $uid         = $user_data->{user_id};

			my $promise = Mojo::Promise->new;

			if ( not $traewelling->{checkin}
				or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
			{
				$self->log->debug("... not checked in");
				return $promise->resolve;
			}
			if (    $traewelling->{status_id}
				and $user_data->{data}{latest_pull_status_id}
				and $traewelling->{status_id}
				== $user_data->{data}{latest_pull_status_id} )
			{
				$self->log->debug("... already handled");
				return $promise->resolve;
			}
			$self->log->debug(
"... checked in : $traewelling->{dep_name} $traewelling->{dep_eva} -> $traewelling->{arr_name} $traewelling->{arr_eva}"
			);
			$self->users->mark_seen( uid => $uid );
			my $user_status = $self->get_user_status($uid);
			if ( $user_status->{checked_in} ) {
				$self->log->debug(
					"... also checked in via travelynx. aborting.");
				return $promise->resolve;
			}

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

			$self->_checkin_hafas_p(
				hafas          => 'DB',
				station        => $traewelling->{dep_eva},
				train_id       => $traewelling->{trip_id},
				uid            => $uid,
				in_transaction => 1,
				db             => $db
			)->then(
				sub {
					$self->log->debug("... handled origin");
					return $self->_checkout_hafas_p(
						hafas          => 'DB',
						station        => $traewelling->{arr_eva},
						train_id       => $traewelling->{trip_id},
						uid            => $uid,
						in_transaction => 1,
						db             => $db
					);
				}
			)->then(
				sub {
					my ( undef, $err ) = @_;
					if ($err) {
						$self->log->debug("... error: $err");
						return Mojo::Promise->reject($err);
					}
					$self->log->debug("... handled destination");
					if ( $traewelling->{message} ) {
						$self->in_transit->update_user_data(
							uid       => $uid,
							db        => $db,
							user_data => { comment => $traewelling->{message} }
						);
					}
					$self->traewelling->log(
						uid     => $uid,
						db      => $db,
						message =>
"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
						status_id => $traewelling->{status_id},
					);

					$self->traewelling->set_latest_pull_status_id(
						uid       => $uid,
						status_id => $traewelling->{status_id},
						db        => $db
					);

					$tx->commit;
					$promise->resolve;
					return;
				}
			)->catch(
				sub {
					my ($err) = @_;
					$self->log->debug("... error: $err");
					$self->traewelling->log(
						uid     => $uid,
						message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
						status_id => $traewelling->{status_id},
						is_error  => 1
					);
					$promise->resolve;
					return;
				}
			)->wait;
			return $promise;
		}
	);

	$self->helper(
		'journeys_to_map_data' => sub {
			my ( $self, %opt ) = @_;

			my @journeys       = @{ $opt{journeys} // [] };
			my $route_type     = $opt{route_type} // 'polybee';
			my $include_manual = $opt{include_manual} ? 1 : 0;

			my $with_polyline = $route_type eq 'beeline' ? 0 : 1;

			if ( not @journeys ) {
				return {
					skipped_journeys    => [],
					station_coordinates => [],
					polyline_groups     => [],
				};
			}

			my $json = JSON->new->utf8;

			my $first_departure = $journeys[-1]->{rt_departure};
			my $last_departure  = $journeys[0]->{rt_departure};

			my @stations = uniq_by { $_->{name} } map {
				{
					name   => $_->{to_name},
					latlon => $_->{to_latlon}
				},
				  {
					name   => $_->{from_name},
					latlon => $_->{from_latlon}
				  }
			} @journeys;

			my @station_coordinates
			  = map { [ $_->{latlon}, $_->{name} ] } @stations;

			my @station_pairs;
			my @polylines;
			my %seen;

			my @skipped_journeys;
			my @polyline_journeys = grep { $_->{polyline} } @journeys;
			my @beeline_journeys  = grep { not $_->{polyline} } @journeys;

			if ( $route_type eq 'polyline' ) {
				@beeline_journeys = ();
			}
			elsif ( $route_type eq 'beeline' ) {
				push( @beeline_journeys, @polyline_journeys );
				@polyline_journeys = ();
			}

			for my $journey (@polyline_journeys) {
				my @polyline = @{ $journey->{polyline} };
				my $from_eva = $journey->{from_eva};
				my $to_eva   = $journey->{to_eva};

				my $from_index
				  = first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
				my $to_index
				  = first_index { $_->[2] and $_->[2] == $to_eva } @polyline;

				# Work around inconsistencies caused by a multiple EVA IDs mapping to the same station name
				if ( $from_index == -1 ) {
					for my $entry ( @{ $journey->{route} // [] } ) {
						if ( $entry->[0] eq $journey->{from_name} ) {
							$from_eva = $entry->[1];
							$from_index
							  = first_index { $_->[2] and $_->[2] == $from_eva }
							@polyline;
							last;
						}
					}
				}

				if ( $to_index == -1 ) {
					for my $entry ( @{ $journey->{route} // [] } ) {
						if ( $entry->[0] eq $journey->{to_name} ) {
							$to_eva = $entry->[1];
							$to_index
							  = first_index { $_->[2] and $_->[2] == $to_eva }
							@polyline;
							last;
						}
					}
				}

				if (   $from_index == -1
					or $to_index == -1 )
				{
					# Fall back to route
					delete $journey->{polyline};
					next;
				}

				my $key
				  = $from_eva . '!'
				  . $to_eva . '!'
				  . ( $to_index - $from_index );

				if ( $seen{$key} ) {
					next;
				}

				$seen{$key} = 1;

				# direction does not matter at the moment
				$key
				  = $to_eva . '!'
				  . $from_eva . '!'
				  . ( $to_index - $from_index );
				$seen{$key} = 1;

				@polyline = @polyline[ $from_index .. $to_index ];
				my @polyline_coords;
				for my $coord (@polyline) {
					push( @polyline_coords, [ $coord->[1], $coord->[0] ] );
				}
				push( @polylines, [@polyline_coords] );
			}

			for my $journey (@beeline_journeys) {

				my @route = @{ $journey->{route} };

				my $from_index = first_index {
					( $_->[1] and $_->[1] == $journey->{from_eva} )
					  or $_->[0] eq $journey->{from_name}
				}
				@route;
				my $to_index = first_index {
					( $_->[1] and $_->[1] == $journey->{to_eva} )
					  or $_->[0] eq $journey->{to_name}
				}
				@route;

				if ( $from_index == -1 ) {
					my $rename = $self->app->renamed_station;
					$from_index = first_index {
						( $rename->{ $_->[0] } // $_->[0] ) eq
						  $journey->{from_name}
					}
					@route;
				}
				if ( $to_index == -1 ) {
					my $rename = $self->app->renamed_station;
					$to_index = first_index {
						( $rename->{ $_->[0] } // $_->[0] ) eq
						  $journey->{to_name}
					}
					@route;
				}

				if (   $from_index == -1
					or $to_index == -1 )
				{
					push( @skipped_journeys,
						[ $journey, 'Start/Ziel nicht in Route gefunden' ] );
					next;
				}

				# Manual journey entries are only included if one of the following
				# conditions is satisfied:
				# * their route has more than two elements (-> probably more than just
				#   start and stop station), or
				# * $include_manual is true (-> user wants to see incomplete routes)
				# This avoids messing up the map in case an A -> B connection has been
				# tracked both with a regular checkin (-> detailed route shown on map)
				# and entered manually (-> beeline also shown on map, typically
				# significantly differs from detailed route) -- unless the user
				# sets include_manual, of course.
				if (    $journey->{edited} & 0x0010
					and @route <= 2
					and not $include_manual )
				{
					push( @skipped_journeys,
						[ $journey, 'Manueller Eintrag ohne Unterwegshalte' ] );
					next;
				}

				@route = @route[ $from_index .. $to_index ];

				my $key = join( '|', map { $_->[0] } @route );

				if ( $seen{$key} ) {
					next;
				}

				$seen{$key} = 1;

				# direction does not matter at the moment
				$seen{ join( '|', reverse map { $_->[0] } @route ) } = 1;

				my $prev_station = shift @route;
				for my $station (@route) {
					push( @station_pairs, [ $prev_station, $station ] );
					$prev_station = $station;
				}
			}

			@station_pairs
			  = uniq_by { $_->[0][0] . '|' . $_->[1][0] } @station_pairs;
			@station_pairs
			  = grep { defined $_->[0][2]{lat} and defined $_->[1][2]{lat} }
			  @station_pairs;
			@station_pairs = map {
				[
					[ $_->[0][2]{lat}, $_->[0][2]{lon} ],
					[ $_->[1][2]{lat}, $_->[1][2]{lon} ]
				]
			} @station_pairs;

			my $ret = {
				skipped_journeys    => \@skipped_journeys,
				station_coordinates => \@station_coordinates,
				polyline_groups     => [
					{
						polylines => $json->encode( \@station_pairs ),
						color     => '#673ab7',
						opacity   => @polylines
						? $with_polyline
						      ? 0.4
						      : 0.6
						: 0.8,
					},
					{
						polylines => $json->encode( \@polylines ),
						color     => '#673ab7',
						opacity   => 0.8,
					}
				],
			};

			if (@station_coordinates) {
				my @lats    = map { $_->[0][0] } @station_coordinates;
				my @lons    = map { $_->[0][1] } @station_coordinates;
				my $min_lat = List::Util::min @lats;
				my $max_lat = List::Util::max @lats;
				my $min_lon = List::Util::min @lons;
				my $max_lon = List::Util::max @lons;
				$ret->{bounds}
				  = [ [ $min_lat, $min_lon ], [ $max_lat, $max_lon ] ];
			}

			return $ret;
		}
	);

	$self->helper(
		'navbar_class' => sub {
			my ( $self, $path ) = @_;

			if ( $self->req->url eq $self->url_for($path) ) {
				return 'active';
			}
			return q{};
		}
	);

	my $r = $self->routes;

	$r->get('/')->to('traveling#homepage');
	$r->get('/about')->to('static#about');
	$r->get('/api')->to('api#documentation');
	$r->get('/changelog')->to('static#changelog');
	$r->get('/impressum')->to('static#imprint');
	$r->get('/imprint')->to('static#imprint');
	$r->get('/tos')->to('static#tos');
	$r->get('/legend')->to('static#legend');
	$r->get('/offline.html')->to('static#offline');
	$r->get('/api/v1/:user_action/:token')->to('api#get_v1');
	$r->get('/login')->to('account#login_form');
	$r->get('/recover')->to('account#request_password_reset');
	$r->get('/recover/:id/:token')->to('account#recover_password');
	$r->get('/reg/:id/:token')->to('account#verify');
	$r->get( '/status/:name' => [ format => [ 'html', 'json' ] ] )
	  ->to( 'profile#user_status', format => undef );
	$r->get( '/status/:name/:ts' => [ format => [ 'html', 'json' ] ] )
	  ->to( 'profile#user_status', format => undef );
	$r->get('/ajax/status/#name')->to('profile#status_card');
	$r->get('/ajax/status/:name/:ts')->to('profile#status_card');
	$r->get( '/p/:name' => [ format => [ 'html', 'json' ] ] )
	  ->to( 'profile#profile', format => undef );
	$r->get( '/p/:name/j/:id' => 'public_journey' )
	  ->to('profile#journey_details');
	$r->get('/.well-known/webfinger')->to('account#webfinger');
	$r->get('/dyn/:av/autocomplete.js')->to('api#autocomplete');
	$r->post('/api/v1/import')->to('api#import_v1');
	$r->post('/api/v1/travel')->to('api#travel_v1');
	$r->post('/action')->to('traveling#travel_action');
	$r->post('/geolocation')->to('traveling#geolocation');
	$r->post('/list_departures')->to('traveling#redirect_to_station');
	$r->post('/login')->to('account#do_login');
	$r->post('/recover')->to('account#request_password_reset');

	if ( $self->config->{traewelling}{oauth} ) {
		$r->get('/oauth/traewelling')->to('traewelling#oauth');
		$r->post('/oauth/traewelling')->to('traewelling#oauth');
	}

	if ( not $self->config->{registration}{disabled} ) {
		$r->get('/register')->to('account#registration_form');
		$r->post('/register')->to('account#register');
	}

	my $authed_r = $r->under(
		sub {
			my ($self) = @_;
			if ( $self->is_user_authenticated ) {
				return 1;
			}
			$self->render(
				'login',
				redirect_to => $self->req->url,
				from        => 'auth_required'
			);
			return undef;
		}
	);

	$authed_r->get('/account')->to('account#account');
	$authed_r->get('/account/privacy')->to('account#privacy');
	$authed_r->get('/account/social')->to('account#social');
	$authed_r->get('/account/social/:kind')->to('account#social_list');
	$authed_r->get('/account/profile')->to('account#profile');
	$authed_r->get('/account/hooks')->to('account#webhook');
	$authed_r->get('/account/traewelling')->to('traewelling#settings');
	$authed_r->get('/account/insight')->to('account#insight');
	$authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
	$authed_r->get( '/cancelled' => [ format => [ 'html', 'json' ] ] )
	  ->to( 'traveling#cancelled', format => undef );
	$authed_r->get('/fgr')->to('passengerrights#list_candidates');
	$authed_r->get('/account/password')->to('account#password_form');
	$authed_r->get('/account/mail')->to('account#change_mail');
	$authed_r->get('/account/name')->to('account#change_name');
	$authed_r->get('/account/select_backend')->to('account#backend_form');
	$authed_r->get('/export.json')->to('account#json_export');
	$authed_r->get('/history.json')->to('traveling#json_history');
	$authed_r->get('/history.csv')->to('traveling#csv_history');
	$authed_r->get('/history')->to('traveling#history');
	$authed_r->get('/history/commute')->to('traveling#commute');
	$authed_r->get('/history/map')->to('traveling#map_history');
	$authed_r->get('/history/:year')->to('traveling#yearly_history');
	$authed_r->get('/history/:year/review')->to('traveling#year_in_review');
	$authed_r->get('/history/:year/:month')->to('traveling#monthly_history');
	$authed_r->get('/journey/add')->to('traveling#add_journey_form');
	$authed_r->get('/journey/comment')->to('traveling#comment_form');
	$authed_r->get('/journey/visibility')->to('traveling#visibility_form');
	$authed_r->get('/journey/:id')->to('traveling#journey_details');
	$authed_r->get('/s/*station')->to('traveling#station');
	$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
	$authed_r->post('/account/privacy')->to('account#privacy');
	$authed_r->post('/account/social')->to('account#social');
	$authed_r->post('/account/profile')->to('account#profile');
	$authed_r->post('/account/hooks')->to('account#webhook');
	$authed_r->post('/account/traewelling')->to('traewelling#settings');
	$authed_r->post('/account/insight')->to('account#insight');
	$authed_r->post('/account/select_backend')->to('account#change_backend');
	$authed_r->post('/journey/add')->to('traveling#add_journey_form');
	$authed_r->post('/journey/comment')->to('traveling#comment_form');
	$authed_r->post('/journey/visibility')->to('traveling#visibility_form');
	$authed_r->post('/journey/edit')->to('traveling#edit_journey');
	$authed_r->post('/journey/passenger_rights/*filename')
	  ->to('passengerrights#generate');
	$authed_r->post('/account/password')->to('account#change_password');
	$authed_r->post('/account/mail')->to('account#change_mail');
	$authed_r->post('/account/name')->to('account#change_name');
	$authed_r->post('/social-action')->to('account#social_action');
	$authed_r->post('/delete')->to('account#delete');
	$authed_r->post('/logout')->to('account#do_logout');
	$authed_r->post('/set_token')->to('api#set_token');
	$authed_r->get('/timeline/in-transit')->to('profile#checked_in');

}

1;
