#!/usr/bin/env perl
## Copyright © 2009-2015 by Daniel Friesel <derf@finalrewind.org>
## License: WTFPL <http://sam.zoy.org/wtfpl>
##   0. You just DO WHAT THE FUCK YOU WANT TO.
use strict;
use warnings;
use 5.010;

no if $] >= 5.018, warnings => "experimental::smartmatch";

use utf8;

use Encode qw(decode);
use Travel::Routing::DE::EFA;
use Exception::Class;
use Getopt::Long qw/:config no_ignore_case/;
use List::Util qw(first);

our $VERSION = '2.12';
my $ignore_info = 'Fahrradmitnahme|Einstiegshilfe';
my $efa;
my $efa_url = 'http://efa.vrr.de/vrr/XSLT_TRIP_REQUEST2';
my ( @from, @to, @via, $from_type, $to_type, $via_type );
my $opt = {
	'efa-url'     => \$efa_url,
	'help'        => sub { show_help(0) },
	'ignore-info' => \$ignore_info,
	'from'        => \@from,
	'to'          => \@to,
	'version'     => sub { say "efa version $VERSION"; exit 0 },
	'via'         => \@via,
};

binmode( STDOUT, ':encoding(utf-8)' );
binmode( STDERR, ':encoding(utf-8)' );

sub show_help {
	my ($exit_status) = @_;

	say 'Usage: efa [options] <from-city> <from-stop> <to-city> <to-stop>';
	say 'See also: man efa';

	exit $exit_status;
}

sub handle_efa_exception {
	my ($e) = @_;

	if ( $e->isa('Travel::Routing::DE::EFA::Exception::Setup') ) {
		if ( $e->message ) {
			printf STDERR (
				"User error: %s (option '%s'): %s\n",
				$e->description, $e->option, $e->message
			);
		}
		else {
			printf STDERR (
				"User error: %s (option '%s', got '%s', want '%s')\n",
				$e->description, $e->option, $e->have, $e->want
			);
		}

		exit 1;
	}
	if ( $e->isa('Travel::Routing::DE::EFA::Exception::Net') ) {
		printf STDERR (
			"Network error: %s: %s\n",
			$e->description, $e->http_response->as_string
		);
		exit 2;
	}
	if ( $e->isa('Travel::Routing::DE::EFA::Exception::NoData') ) {
		printf STDERR ( "Backend error: %s\n", $e->description );
		exit 3;
	}
	if ( $e->isa('Travel::Routing::DE::EFA::Exception::Ambiguous') ) {
		printf STDERR (
			"Backend error: The %s '%s' is ambiguous. Try one of %s\n",
			$e->post_key, $e->post_value,, $e->possibilities
		);
		exit 4;
	}
	if ( $e->isa('Travel::Routing::DE::EFA::Exception::Other') ) {
		printf STDERR ( "Backend error: %s: %s\n", $e->description,
			$e->message );
		exit 5;
	}

	printf STDERR ( "Unknown error: %s\n%s", ref($e), $e->trace );
	exit 10;
}

sub check_for_error {
	my ($eval_error) = @_;

	if ( not defined $efa ) {
		if (    $eval_error
			and ref($eval_error)
			and $eval_error->isa('Travel::Routing::DE::EFA::Exception') )
		{
			handle_efa_exception($eval_error);
		}
		elsif ($eval_error) {
			printf STDERR
			  "Unknown Travel::Routing::DE::EFA error:\n${eval_error}";
			exit 10;
		}
		else {
			say STDERR 'Travel::Routing::DE::EFA failed to return an object';
			exit 10;
		}
	}

	return;
}

sub format_footpath {
	my @parts = @_;
	my $str   = q{};

	for my $path_elem (@parts) {
		my ( $type, $level ) = @{$path_elem};
		if ( $level eq 'UP' ) {
			$str .= ' ↗';
		}
		elsif ( $level eq 'DOWN' ) {
			$str .= ' ↘';
		}
		elsif ( $level eq 'LEVEL' ) {
			$str .= ' →';
		}
		else {
			$str .= " [unhandled level, please report a bug : $level]";
		}
	}

	return $str;
}

sub display_connection {
	my ($c) = @_;

	if ( $c->delay ) {
		printf( "# +%d,  Plan: %s -> %s\n",
			$c->delay, $c->departure_stime, $c->arrival_stime );
	}

	for my $note ( $c->regular_notes ) {
		my $text = $note->summary;
		if ( not( length $ignore_info and $text =~ /$ignore_info/i ) ) {
			say "# $text";
		}
	}

	for my $notice ( $c->current_notes ) {
		if ( $notice->subtitle ne $notice->subject ) {
			printf( "# %s - %s\n", $notice->subtitle, $notice->subject );
		}
		else {
			printf( "# %s\n", $notice->subtitle );
		}
	}

	if ( $opt->{maps} ) {
		for my $m ( $c->departure_routemaps, $c->departure_stationmaps ) {
			say "# $m";
		}
	}

	printf(
		"%-5s ab  %-30s %-20s %s\n",
		$c->departure_time,
		$c->departure_stop_and_platform,
		$c->train_line || $c->train_product,
		$c->train_destination,
	);

	if ( $opt->{'full-route'} ) {
		for my $via_stop ( $c->via ) {
			printf( "%-5s     %-30s %s\n",
				$via_stop->[1], $via_stop->[2], $via_stop->[3] );
		}
	}

	printf( "%-5s an  %s\n", $c->arrival_time, $c->arrival_stop_and_platform, );
	print "\n";

	if (    $opt->{'extended-info'}
		and $c->footpath_duration
		and $c->footpath_type ne 'IDEST' )
	{
		printf( "%5d min umsteigen: %s\n\n",
			$c->footpath_duration, format_footpath( $c->footpath_parts ) );
	}

	return;
}

@ARGV = map { decode( 'UTF-8', $_ ) } @ARGV;

#<<<
GetOptions(
	$opt,
	qw{
		arrive|a=s
		auto-url|discover-and-print|A
		bike|b
		date|d=s
		depart|time|t=s
		devmode
		discover|D
		efa-url|u=s
		exclude|e=s@
		extended-info|E
		from=s@{2}
		full-route|f
		help|h
		ignore-info|I:s
		include|i=s
		list|l
		maps|M
		max-change|m=i
		num-connections|n=i
		prefer|P=s
		proximity|p:10
		service|s=s
		timeout=i
		to=s@{2}
		version|v
		via=s@{2}
		walk-speed|w=s
	},
) or show_help(1);
#>>>

if ( $opt->{list} ) {
	printf( "%-40s %-14s %s\n\n", 'service', 'abbr. (-s)', 'url (-u)' );
	for my $service ( Travel::Routing::DE::EFA::get_efa_urls() ) {
		printf( "%-40s %-14s %s\n", @{$service}{qw(name shortname url)} );
	}
	exit 0;
}

if ( $opt->{arrive} and $opt->{depart} ) {
	print STDERR 'Note: The options -a/--arrive and -t/--time/--depart are'
	  . " mutually exclusive\n"
	  . "      Discarding the --arrive option\n\n";
	delete $opt->{arrive};
}

if ( not( @from and @to ) ) {
	if ( @ARGV == 4 ) {
		( @from[ 0, 1 ], @to[ 0, 1 ] ) = @ARGV;
	}
	elsif ( @ARGV == 6 ) {
		( @from[ 0, 1 ], @via[ 0, 1 ], @to[ 0, 1 ] ) = @ARGV;
	}
	else {
		show_help(1);
	}
}

for my $pair ( [ \@from, \$from_type ], [ \@via, \$via_type ],
	[ \@to, \$to_type ], )
{

	next if ( not defined $pair->[0]->[1] );

	if (
		$pair->[0]->[1] =~ s{ ^ (?<type> [^:]+ ) : \s* (?<target> .+ ) $ }
		{$+{target}}x
	  )
	{
		given ( $+{type} ) {
			when ('addr') { ${ $pair->[1] } = 'address' }
			default       { ${ $pair->[1] } = $+{type} }
		}
	}
}

if ( defined $opt->{'ignore-info'} and length( $opt->{'ignore-info'} ) == 0 ) {
	$opt->{'ignore-info'} = undef;
}

if ( $opt->{exclude} ) {
	$opt->{exclude} = [ split( /,/, join( ',', @{ $opt->{exclude} } ) ) ];
}

if ( $opt->{service} ) {
	my $service = first { lc( $_->{shortname} ) eq lc( $opt->{service} ) }
	Travel::Routing::DE::EFA::get_efa_urls();
	if ( not $service ) {
		printf STDERR (
			"Error: Unknown service '%s'. See 'efa -l' for a "
			  . "list of supported service names\n",
			$opt->{service}
		);
		exit 1;
	}
	$efa_url = $service->{url};
}

if ( $opt->{discover} or $opt->{'auto-url'} ) {
	for my $service ( Travel::Routing::DE::EFA::get_efa_urls() ) {
		$efa = eval {
			Travel::Routing::DE::EFA->new(
				efa_url => $service->{url},

				origin      => [ @from, $from_type ],
				destination => [ @to,   $to_type ],
				via => ( @via ? [ @via, $via_type ] : undef ),

				arrival_time   => $opt->{arrive},
				departure_time => $opt->{depart},
				date           => $opt->{date},
				exclude        => $opt->{exclude},
				train_type     => $opt->{include},
				with_bike      => $opt->{bike},

				select_interchange_by => $opt->{prefer},
				use_near_stops        => $opt->{proximity},
				walk_speed            => $opt->{'walk-speed'},
				max_interchanges      => $opt->{'max-change'},
				num_results           => $opt->{'num-connections'},

				developer_mode => $opt->{devmode},
				lwp_options    => { timeout => $opt->{timeout} },
			);
		};
		if ($efa) {
			if ( $opt->{'auto-url'} ) {
				last;
			}
			printf(
				"%s / %s (%s)\n   ->  efa -s %s %s\n\n",
				@{$service}{qw(name shortname url shortname)},
				join( ' ', map { "'$_'" } @ARGV ),
			);
		}
	}
	if ( $opt->{'discover'} ) {
		exit 0;
	}
}
else {
	$efa = eval {
		Travel::Routing::DE::EFA->new(
			efa_url => $efa_url,

			origin      => [ @from, $from_type ],
			destination => [ @to,   $to_type ],
			via => ( @via ? [ @via, $via_type ] : undef ),

			arrival_time   => $opt->{arrive},
			departure_time => $opt->{depart},
			date           => $opt->{date},
			exclude        => $opt->{exclude},
			train_type     => $opt->{include},
			with_bike      => $opt->{bike},

			select_interchange_by => $opt->{prefer},
			use_near_stops        => $opt->{proximity},
			walk_speed            => $opt->{'walk-speed'},
			max_interchanges      => $opt->{'max-change'},
			num_results           => $opt->{'num-connections'},

			developer_mode => $opt->{devmode},
			lwp_options    => { timeout => $opt->{timeout} },
		);
	};
}

check_for_error($@);

my @routes = $efa->routes;

for my $i ( 0 .. $#routes ) {

	my $route = $routes[$i];

	if ( $opt->{'extended-info'} ) {
		print '# ' . $route->duration;
		if ( $route->ticket_type ) {
			printf( ", Preisstufe %s (%s€ / %s€)\n\n",
				$route->ticket_type, $route->fare_adult, $route->fare_child, );
		}
		else {
			print "\n\n";
		}
	}

	for my $c ( $route->parts ) {
		display_connection($c);
	}

	# last one needs to be shown separately
	if ( $opt->{maps} ) {
		my $c = ( $route->parts )[-1];
		for my $m ( $c->arrival_routemaps, $c->arrival_stationmaps ) {
			say "# $m";
		}
	}

	if ( $i != $#routes ) {
		print "---------\n\n";
	}
}

__END__

=head1 NAME

efa - unofficial efa.vrr.de command line client

=head1 SYNOPSIS

=over

=item B<efa> B<--from> I<city> I<stop> B<--to> I<city> I<stop> [ I<additional options> ]

=item B<efa> [ I<options> ] I<from-city> I<from-stop> [ I<via-city> I<via-stop> ] I<to-city> I<to-stop>

=back

=head1 VERSION

version 2.12

=head1 DESCRIPTION

B<efa> is a command line client for the L<http://efa.vrr.de> web interface.
It sends the specified information to the online form and displays the results.

It also supports other EFA services than L<http://efa.vrr.de>.  B<efa> has a
builtin list of EFA entry points which can be probed with the B<-A> and B<-D>
options and listed with B<-l>. You can also specify a custom service using
B<-u> I<url> or B<-s> I<name>.
However, the default EFA service is sufficient in most cases (even ICE
connections all over Germany).

=head1 OPTIONS

=over

=item B<--from> I<city> I<stop>

Departure place

=item B<--to> I<city> I<stop>

Arrival place

=item B<--via> I<city> I<stop>

Travel via this place

In all cases, if you want I<stop> to be an address or "point of interest", you
can set it to 'addr:something' or 'poi:something'.

=item B<-a>|B<--arrive> I<hh>:I<mm>

Journey end time (overrides --time/--depart)

=item B<-A>|B<--auto-url>|B<--discover-and-print>

Probe all known EFA entry points for the specified connection. Print the first
result which was not an error.

Note that this may take a while and will not necessarily return the best
result.  Also, using this option by default is not recommended, as it puts
EFA services under considerable additional load.

=item B<-b>|B<--bike>

Request connections allowing the passenger to take a bike with them.
Note that this may cause B<efa> to display no routes at all -- In that case,
the backend was unable to find such connections or didn't know about
their bike-support.

=item B<-d>|B<--date> I<dd>.I<mm>.[I<yyyy>]

Journey date

=item B<-D>|B<--discover>

Probe all known EFA entry points for the specified connection. No routes are
returned in this case. Instead, B<efa> will print the URLs and names of all
entry points which did not return an error.

=item B<-u>|B<--efa-url> I<url>

URL to the EFA entry point, defaults to
L<http://efa.vrr.de/vrr/XSLT_TRIP_REQUEST2>.  Depending on your location, some
I<url>s may contain more specific data than others. See
Travel::Routing::DE::EFA(3pm) and the B<-l> option for alternatives.

=item B<-e>|B<--exclude> I<transports>

Exclude I<transports> (comma separated list).

Possible transports: zug, s-bahn, u-bahn, stadtbahn, tram, stadtbus, regionalbus,
schnellbus, seilbahn, schiff, ast, sonstige

=item B<-E>|B<--extended-info>

Display more than just the basic route information. At the moment, the
following is displayed (if available):

=over

=item * Route duration

=item * Ticket fare

=item * transfer duration and elevation changes (via stairs / escalators)
at each stop

=back

=item B<-f>|B<--full-route>

Display intermediate stops (with time and platform) of each train.  Note that
these are not always available.

=item B<-I>|B<--ignore-info> [ I<regex> ]

Ignore additional information matching I<regex>
(default: /Fahrradmitnahme|Einstiegshilfe/)

If I<regex> is not supplied, removes the default regex (-E<gt> nothing will be ignored)

=item B<-i>|B<--include> I<type>

Include connections using trains of type I<type>, where I<type> may be:

=over

=item * local (default)

only take local trains ("Verbund-/Nahverkehrslinien"). Slow, but the cheapest
method if you're not traveling long distance

=item * ic

Local trains + IC

=item * ice

All trains (local + IC + ICE)

=back

=item B<-l>|B<--list>

List supported EFA services wit their URLs (see B<-u>) and abbreviations
(see B<-s>).

=item B<-M>|B<--maps>

Output links to maps of transfer paths and transfer stations where
available.

=item B<-m>|B<--max-change> I<number>

Print connections with at most I<number> interchanges

=item B<-n>|B<--num-connections> I<number>

Return up to I<number> connections.  If unset, the default of the respective
EFA server is used (usually 4 or 5).

=item B<-P>|B<--prefer> I<type>

Prefer connections of I<type>:

=over

=item * speed (default)

The faster, the better

=item * nowait

Prefer connections with less interchanges

=item * nowalk

Prefer connections with less walking (at interchanges)

=back

=item B<-p>|B<--proximity> [I<minutes>]

Take stops close to the stop/start into account.  By default, up to 10 minutes
of walking to/from the route's first/last stop is considered acceptable. You
can specify I<minutes> to use a custem value.

=item B<-s>|B<--service> I<name>

Shortname of the EFA entry point. See Travel::Routing::DE::EFA(3pm) and
the B<-l> option for a list of services.

=item B<-t>|B<--time>|B<--depart> I<hh>:I<mm>

Journey start time

=item B<--timeout> I<seconds>

Set timeout for HTTP requests. Default: 60 seconds.

=item B<-v>|B<--version>

Print version information

=item B<-w>|B<--walk-speed> I<speed>

Set your walking speed to I<speed>.
Accepted values: normal (default), fast, slow

=back

=head1 EXIT STATUS

    0    Everything went well
    1    Invalid arguments, see error message
    2    Network error, unable to send request
    3    efa.vrr.de did not return any parsable data
    4    efa.vrr.de error: ambiguous input
    5    efa.vrr.de error: no connections found
    10   Unknown Travel::Routing::DE::EFA error
    255  Other internal error

=head1 CONFIGURATION

None.

=head1 EXAMPLES

=over

=item efa Do Hbf MH Hbf

Look up a connection from Dortmund (Do) Hbf to ME<uuml>lheim (MH) Hbf

=item efa --include ice Essen Hbf Hamburg Dammtor

Look up a connection with long-distance trains

=item efa --arrive 18:00 -e zug,s-bahn -M E Wickenburgstr D Oststr

Look up a connection from Essen Wickenburgstr to DE<uuml>sseldorf Oststr.
Do not use any trains, make sure to arrive around 18:00 and print links to
maps of all interchange stations.

=item efa -s vvs Stuttgart Hbf Stuttgart Marienplatz

Use the VVS (Verkehrsverbund Stuttgart) EFA service to look up a connection.

=back

=head1 DEPENDENCIES

This script requires perl 5.10 (or higher) with the following modules:

=over

=item * Class::Accessor

=item * Exception::Class

=item * LWP::UserAgent

=item * XML::LibXML

=back

=head1 BUGS AND LIMITATIONS

The EFA backend is not able to calculate "two-way" routes, i.e. from -> via ->
to routes with from == to. If from and to are the same stop, it doesn't even
try to calculate a route ("we recommend walking instead"), if they are close to
each other it may or may not work.  Workaround: Request from -> via using the
normal four-argument efa invocation, read the time, use efa -t time via via to
to to request via -> to.

=head1 AUTHOR

Copyright (C) 2009-2015 by Daniel Friesel E<lt>derf@finalrewind.orgE<gt>

=head1 LICENSE

  0. You just DO WHAT THE FUCK YOU WANT TO.