#!/usr/bin/perl
#
# DW::Routing
#
# Module to allow calling non-BML controller/views.
#
# Authors:
#      Andrea Nall <anall@andreanall.com>
#      Mark Smith <mark@dreamwidth.org>
#
# Copyright (c) 2009-2013 by Dreamwidth Studios, LLC.
#
# This program is free software; you may redistribute it and/or modify it under
# the same terms as Perl itself.  For a copy of the license, please reference
# 'perldoc perlartistic' or 'perldoc perlgpl'.
#

package DW::Routing;
use strict;

use LJ::ModuleLoader;
use DW::Template;
use LJ::JSON;
use DW::Request;
use DW::Routing::CallInfo;
use Carp qw/croak/;

# IMPORTANT!
#
# If we change the internal representation here, the code in
# bin/dev/lookup-routing must also be updated.
#
# Thank you.
#
our %string_choices;
our %regex_choices = (
    app  => [],
    user => [],
    api  => []
);
our %api_endpoints;         # ver => { string => hash }
our %api_rest_endpoints;    # ver => { string => hash }

our $T_TESTING_ERRORS;

my $default_content_types = {
    atom  => 'application/atom+xml; charset=utf-8',
    html  => 'text/html; charset=utf-8',
    js    => 'application/javascript; charset=utf-8',
    json  => 'application/json; charset=utf-8',
    plain => 'text/plain; charset=utf-8',
    png   => 'image/png',
};

LJ::ModuleLoader->require_subclasses('DW::Controller')
    unless $DW::Routing::DONT_LOAD;    # for testing

=head1 NAME

DW::Routing - Module to allow calling non-BML controller/views.

=head1 Page Call API

=head2 C<< $class->call( $r, %opts ) >>

Valid options:

=over

=item uri - explicitly override the uri
=item role - explicitly define the role
=item username - define the username, implies username role

=back

This method should be directly returned by the caller if defined.

=cut

sub call {
    my $class     = shift;
    my $call_opts = $class->get_call_opts(@_);

    return $class->call_hash($call_opts) if defined $call_opts;
    return undef;
}

=head2 C<< $class->get_call_opts( $r, %opts ) >>

Valid options:

=over

=item uri - explicitly override the uri
=item role - explicitly define the role
=item username - define the username, implies username role

=back

Returns a call_opts hash, or undefined.

=cut

sub get_call_opts {
    my ( $class, %opts ) = @_;
    my $r = DW::Request->get;

    my $uri    = $opts{uri} || $r->uri;
    my $format = undef;
    ( $uri, $format ) = ( $1, $2 )
        if $uri =~ m/^(.+?)\.([a-z]+)$/;

    # discard format if the caller requested bml from an ancient link
    if ( $format && $format eq 'bml' ) {
        $format = undef;
    }

    # Role determination: if the URL starts with '/api/vX' then it's an API
    # call, and we should extract that information for our call options.
    if ( $uri =~ m!^/api/v(\d+)(/.+)$! ) {
        $opts{role}   = 'api';
        $opts{apiver} = $1 + 0;
        $format       = 'json';
        $uri          = $2;
    }

    # add more data to the options hash, we'll need it
    $opts{role} ||= $opts{username} ? 'user' : 'app';
    $opts{uri}    = $uri;
    $opts{format} = $format;

    # we construct this object as an easy way to get options later, it gives
    # us accessors.
    my $call_opts = DW::Routing::CallInfo->new( \%opts );

    # APIs are versioned, so we only want to check for endpoints that match
    # the version the user is requesting.
    if ( $call_opts->role eq 'api' ) {

        # return early if we weren't given an API version
        return unless defined( $call_opts->apiver );

        # check the static endpoints for this api version first
        if ( exists $api_endpoints{ $call_opts->apiver } ) {
            my $hash = $api_endpoints{ $call_opts->apiver }->{$uri};
            if ($hash) {
                $call_opts->init_call_opts($hash);
                return $call_opts;
            }
        }

        # if there's no static match, check the regexes
        my $endpoints_for_version = $api_rest_endpoints{ $call_opts->apiver };
        if ($endpoints_for_version) {

            # check for a match for each regex in this version
            foreach my $regex ( keys %{$endpoints_for_version} ) {

                # this actually checks the regex and, if there's a match,
                # populates the @args with matched groups
                if ( ( my @args = $uri =~ $regex ) ) {
                    my $call_def = $endpoints_for_version->{$regex};
                    $call_opts->init_call_opts( $call_def, \@args );
                    return $call_opts;
                }
            }
        }

        # if it's not found in either, just return.
        return;
    }

    # try the string options first as they're fast
    my $hash = $string_choices{ $call_opts->role . $uri };
    if ( defined $hash ) {
        $call_opts->init_call_opts($hash);
        return $call_opts;
    }

    # try the regex choices next
    # FIXME: this should be a dynamically sorting array so the most used items float to the top
    # for now it doesn't matter so much but eventually when everything is in the routing table
    # that will have to be done
    my @args;
    foreach $hash ( @{ $regex_choices{ $call_opts->role } } ) {
        if ( ( @args = $uri =~ $hash->{regex} ) ) {
            $call_opts->init_call_opts( $hash, \@args );
            return $call_opts;
        }
    }

    # failed to find anything so fall through
    return undef;
}

=head2 C<< $class->call_hash( $class, $call_opts ) >>

Calls the raw hash.

=cut

sub call_hash {
    my ( $class, $opts ) = @_;
    my $r = DW::Request->get;

    my $hash = $opts->call_opts;
    return undef unless $hash && $hash->{sub};

    $r->pnote( routing_opts => $opts );
    return $r->call_response_handler( \&_call_hash );
}

# INTERNAL METHOD: no POD
# Perl Response Handler for call_hash
sub _call_hash {
    my $r    = DW::Request->get;
    my $opts = $r->pnote('routing_opts');

    $opts->prepare_for_call;

    # check method
    my $method = uc( $r->method );
    return $r->HTTP_METHOD_NOT_ALLOWED unless $opts->method_valid($method);

    # check for format validity
    return $r->NOT_FOUND unless $opts->format_valid;

    # if renamed with redirect in place, then do the redirect
    if ( $opts->role eq 'user' && ( my $orig_u = LJ::load_user( $opts->username ) ) ) {
        my $renamed_u = $orig_u->get_renamed_user;

        if ( $renamed_u && !$orig_u->equals($renamed_u) ) {
            my $journal_host = $renamed_u->journal_base;
            $journal_host =~ s!https?://!!;

            return $r->redirect( LJ::create_url( $r->uri, host => $journal_host, keep_args => 1 ) );
        }
    }

    # apply default content type if it exists
    my $format = $opts->format;
    $r->content_type( $default_content_types->{$format} )
        if $default_content_types->{$format};

    # apply default cache-avoidant settings to "journal" content
    # (similar to the behavior of our Apache server modules)
    # so that proxies (e.g. Cloudflare) must revalidate the response
    if ( $opts->role eq 'user' && !$opts->no_cache ) {
        $r->header_out( "Cache-Control" => "private, proxy-revalidate" );
    }

    # apply no-cache if needed
    $r->no_cache if $opts->no_cache;

    # try to call the handler that actually does the content creation; it will
    # return either a number (HTTP code), or undef
    # means there was an error of some sort
    my $ret = eval { $opts->call };
    return $ret unless $@;

    # here down is simply error handling for whatever the handler sub above
    # might have died with
    my $msg = $@;

    my $err = LJ::errobj($msg)
        or die "LJ::errobj didn't return anything.";
    unless ($T_TESTING_ERRORS) {
        warn $msg;
    }

    # JSON error rendering
    if ( $format eq 'json' ) {
        $msg = $err->as_string;
        chomp $msg;

        my $text   = $LJ::MSG_ERROR || "Sorry, there was a problem.";
        my $remote = LJ::get_remote();

        $text = "$msg" if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER;

        $r->status(500);
        $r->print( to_json( { success => 0, error => $text } ) );
        return $r->OK;

        # default error rendering
    }
    elsif ( $format eq "html" ) {
        $msg = $err->as_html;
        chomp $msg;

        $msg .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME;

        $r->status(500);
        $r->content_type( $default_content_types->{html} );

        my $text   = $LJ::MSG_ERROR || "Sorry, there was a problem.";
        my $remote = LJ::get_remote();
        $text = "<b>[Error: $msg]</b>"
            if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER;
        $opts->{no_sitescheme} = 1 if $T_TESTING_ERRORS;

        $ret = eval { return DW::Template->render_string( $text, $opts ); };
        return $ret unless $@;

        my $msg2 = $@;
        my $err2 = LJ::errobj($msg2)
            or die "LJ::errobj didn't return anything.";
        unless ($T_TESTING_ERRORS) {
            warn $msg2;
        }

        if ( ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER ) {
            $msg2 = $err2->as_html;
            $msg2 .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME;

            $text .= "\n<br/><br/>Additionally, while trying to render this error page:";
            $text .= "\n<b>[Error 2: $msg2]</b>";
        }

        $r->status(500);
        $r->content_type('text/html');
        $r->print($text);
        return $r->OK;
    }
    else {
        $msg = $err->as_string;
        chomp $msg;

        $msg .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME;

        my $text   = $LJ::MSG_ERROR || "Sorry, there was a problem.";
        my $remote = LJ::get_remote();
        $text = "Error: $msg" if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER;

        $r->status(500);
        $r->content_type('text/plain');
        $r->print($text);

        return $r->OK;
    }
}

# INTERNAL METHOD: no POD
# controller sub for register_static
sub _static_helper {
    my $r = DW::Request->get;
    return DW::Template->render_template( $_[0]->args );
}

# INTERNAL METHOD: no POD
# controller sub for register_redirect
sub _redirect_helper {
    my $r    = DW::Request->get;
    my $data = $_[0]->args;

    my $dest = $data->{dest};
    if ( ref $dest eq "CODE" ) {
        my $get = $r->get_args;
        $dest = $dest->( { map { $_ => LJ::eurl( $get->{$_} ) } keys %$get } );
    }

    if ( $data->{full_uri} ) {
        return $r->redirect($dest);
    }
    else {
        return $r->redirect( LJ::create_url( $dest, keep_args => $data->{keep_args} ) );
    }
}

=head1 Registration API

=head2 C<< $class->register_static( $string, $filename, %opts ) >>

Static page helper.

=over

=item string - path

=item filename - template filename

=item Opts ( see register_string )

=back

=cut

sub register_static {
    my ( $class, $string, $fn, %opts ) = @_;

    $opts{args} = $fn;
    $class->register_string( $string, \&_static_helper, %opts );
}

=head2 C<< $class->register_string( $string, $sub, %opts ) >>

=over

=item string - path

=item sub - sub

=item Opts:

=over

=item args - passed verbatim to sub.

=item app - Serve this in app-space.

=item user - Serve this in journalspace.

=item format - What format should be used, defaults to HTML

=item formats - An array of possible formats, or 1 to allow everything.

=back

=back

=cut

sub register_string {
    my ( $class, $string, $sub, %opts ) = @_;

    my $hash = _apply_defaults(
        \%opts,
        {
            sub => $sub,
        }
    );

    $string_choices{ 'app' . $string }  = $hash if $hash->{app};
    $string_choices{ 'user' . $string } = $hash if $hash->{user};

    my %redirect_opts = (
        app          => $hash->{app},
        user         => $hash->{user},
        formats      => $hash->{formats},
        format       => $hash->{format},
        no_redirects => 1,
        keep_args    => 1,
    );

    if ( $string =~ m!(^(.*)/)index$! && !exists $opts{no_redirects} ) {
        $class->register_redirect( $2, $1, %redirect_opts ) if $2;
        $string_choices{ 'app' . $1 }  = $hash if $hash->{app};
        $string_choices{ 'user' . $1 } = $hash if $hash->{user};

    }
    elsif ( !exists $opts{no_redirects} ) {

        # for all other (non-index) pages, redirect page/ to page
        $class->register_redirect( "$string/", $string, %redirect_opts );

    }
}

=head2 C<< $class->register_redirect( $string, $dest, %opts ) >>

Redirect helper.

=over

=item string - path

=item dest - destination

=item Opts ( see register_string )

=over

=item keep_args - Persist GET arguments over redirect ( same as the keep_args argument to create_url ).

=item full_uri - A full URI ( http://...../foo v.s. /foo )

=back

=back

=cut

sub register_redirect {
    my ( $class, $string, $dest, %opts ) = @_;

    my $args = { dest => $dest };
    $args->{keep_args} = delete $opts{keep_args} || 0;
    $args->{full_uri}  = delete $opts{full_uri}  || 0;

    $opts{args} = $args;
    $class->register_string( $string, \&_redirect_helper, %opts );
}

=head2 C<< $class->register_regex( $regex, $sub, %opts ) >>

=over

=item regex

=item sub - sub

=item Opts ( see register_string )

=back

=cut

sub register_regex {
    my ( $class, $regex, $sub, %opts ) = @_;

    my $hash = _apply_defaults(
        \%opts,
        {
            regex => $regex,
            sub   => $sub,
        }
    );
    push @{ $regex_choices{app} },  $hash if $hash->{app};
    push @{ $regex_choices{user} }, $hash if $hash->{user};
    push @{ $regex_choices{api} },  $hash if $hash->{api};
}

=head2 C<< $class->register_rpc( $name, $sub, %opts ) >>

Register a RPC call

=over

=item name - RPC call name

=item sub - sub

=item Opts ( see register_string )

=back

=cut

sub register_rpc {
    my ( $class, $string, $sub, %opts ) = @_;

    delete $opts{app};
    delete $opts{user};
    $class->register_string( "/__rpc_$string", $sub, app => 1, user => 1, %opts );

    # FIXME: per Bug 4900, this line is temporary and can go away as soon as
    #  all the javascript is updated
    $class->register_regex( qr!^/[^/]+/\Q__rpc_$string\E$!, $sub, app => 1, user => 1, %opts );
}

=head2 C<< $class->register_api_endpoint( $string, $sub, %opts ) >>

=over

=item string

=item sub - sub

=item opts (see register_string)

=back

=cut

sub register_api_endpoint {
    my ( $class, $string, $sub, %opts ) = @_;
    croak 'register_api_endpoint must have version option'
        unless exists $opts{version};

    my $hash = _apply_defaults(
        \%opts,
        {
            sub    => $sub,
            format => 'json',
        }
    );

    my $vers =
        ref $opts{version} eq 'ARRAY'
        ? $opts{version}
        : [ $opts{version} + 0 ];
    croak 'register_api_version requires all versions >= 1'
        if grep { $_ <= 0 } @$vers;

    # Now register this string at all versions that they gave us.
    $api_endpoints{$_}->{$string} = $hash foreach @$vers;
}

# internal helper for speed construction ...
sub register_api_endpoints {
    my $class = shift;
    foreach my $row (@_) {
        $class->register_api_endpoint( $row->[0], $row->[1], version => $row->[2] );
    }
}

=head2 C<< $class->register_api_rest_endpoint( $string, $sub, %opts ) >>

=over

=item string

=item sub - sub

=item opts (see register_regex)

=back

=cut

sub register_api_rest_endpoint {
    my ( $class, $string, $sub, $controller_class, %opts ) = @_;

    croak 'register_api_rest_endpoint must have version option'
        unless exists $opts{version};

    my $hash = _apply_defaults(
        \%opts,
        {
            class  => $controller_class,
            sub    => $sub,
            format => 'json',
        }
    );

    my $vers =
        ref $opts{version} eq 'ARRAY'
        ? $opts{version}
        : [ $opts{version} + 0 ];
    croak 'register_api_version requires all versions >= 1'
        if grep { $_ <= 0 } @$vers;

    # Now register this string at all versions that they gave us.
    $api_rest_endpoints{$_}->{$string} = $hash foreach @$vers;
}

# internal helper for speed construction ...
sub register_api_rest_endpoints {
    my $class = shift;
    foreach my $row (@_) {
        $class->register_api_rest_endpoint( $row->[0], $row->[1], $row->[2], version => $row->[3] );
    }
}

# internal method, intentionally no POD
# applies default for opts and hash
sub _apply_defaults {
    my ( $opts, $hash ) = @_;

    $hash ||= {};
    $opts->{app}  = 1 if !defined $opts->{app} && !$opts->{user} && !$opts->{api};
    $hash->{args} = $opts->{args};
    $hash->{app}  = $opts->{app} || 0;
    $hash->{user} = $opts->{user} || 0;
    $hash->{api}  = $opts->{api} || 0;
    $hash->{format} ||= $opts->{format} || 'html';
    $hash->{no_cache} = $opts->{no_cache} || 0;

    my $formats = $opts->{formats} || [ $hash->{format} ];
    $formats = { map { ( $_, 1 ) } @$formats } if ref $formats eq 'ARRAY';

    $hash->{formats} = $formats;
    $hash->{methods} = $opts->{methods} || { GET => 1, POST => 1, HEAD => 1, DELETE => 1 };

    return $hash;
}

=head1 AUTHOR

=over

=item Andrea Nall <anall@andreanall.com>

=item Mark Smith <mark@dreamwidth.org>

=back

=head1 COPYRIGHT AND LICENSE

Copyright (c) 2009-2013 by Dreamwidth Studios, LLC.

This program is free software; you may redistribute it and/or modify it under
the same terms as Perl itself. For a copy of the license, please reference
'perldoc perlartistic' or 'perldoc perlgpl'.

=cut

1;
