The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Pinwheel::Mapper;

use strict;
use warnings;


sub new
{
    my $class = shift;
    my $self = bless({}, $class);
    $self->reset();
    return $self;
}


sub reset
{
    my $self = shift;
    $self->{routes} = [];
    $self->{named} = {};
}


sub connect
{
    my $self = shift;
    my $name = (scalar(@_) & 1) ? undef : shift;
    my $route = _tidy_path(shift);
    my %options = @_;
    my $defaults = delete $options{'defaults'} || {};
    my $requirements = delete $options{'requirements'} || {};
    my $conditions = delete $options{'conditions'} || {};
    my @matchkeys = ();
    my %target = ();
    my $regexp;

    $options{'_static'} = 1 if ($route =~ /^\w+:\/\//);

    while (my ($key, $value) = each(%options)) {
        $defaults->{$key} = $value unless $key =~ /^_/;
    }

    my $subfn = sub {
        my ($prefix, $type, $name) = @_;
        my $pattern;

        push @matchkeys, $name;
        if ($name eq 'controller' || $name eq 'action') {
            $target{$name} = '*';
        } elsif ($name eq 'id') {
            $defaults->{id} ||= undef;
        }
        $prefix = '\.' if ($prefix eq '.');
        if ($type eq '*') {
            $pattern = $prefix . '(.*)';
        } elsif (exists($requirements->{$name})) {
            $pattern = $prefix . '(' . $requirements->{$name} . ')';
        } elsif ($prefix eq '\.') {
            $pattern = $prefix . '([^/.][^/]*)';
        } else {
            $pattern = $prefix . '([^/.]+)';
        }
        $pattern = "(?:$pattern)?" if exists($defaults->{$name});
        return $pattern;
    };

    $regexp = $route;
    $regexp =~ s!([/.]?)([:*])\(?([a-z][a-z0-9_]*)\)?!&$subfn($1, $2, $3)!ge;
    $defaults->{'controller'} ||= 'content';
    $defaults->{'action'} ||= 'index';
    if (!$target{'controller'}) {
        $target{'controller'} = $defaults->{'controller'};
    }
    if (!$target{'action'}) {
        $target{'action'} = $defaults->{'action'};
    }

    foreach (keys %$requirements) {
        $requirements->{$_} = qr/^$requirements->{$_}$/;
    }
    if ($conditions->{method} && $conditions->{method} eq 'any') {
        $conditions->{method} = undef;
    }

    my $r = {
        name => $name,
        route => $route,
        regexp => qr/^${regexp}$/,
        matchkeys => \@matchkeys,
        defaults => $defaults,
        requirements => $requirements,
        conditions => $conditions,
        target => \%target,
        options => \%options
    };
    push @{$self->{routes}}, $r unless $options{'_static'};
    $self->{named}{$name} = $r if defined($name);
}


sub match
{
    my ($self, $path, $method) = @_;
    my ($k, $v, %params, @matches, $route);

    $path = _tidy_path($path);
    $method = undef if $method && $method eq 'any';

    foreach $route (@{$self->{routes}}) {
        @matches = ($path =~ /$route->{regexp}/);
        next if scalar(@matches) == 0;
        if (defined($method) && defined($route->{conditions}{method})) {
            next if $method ne $route->{conditions}{method};
        }
        foreach $k (@{$route->{matchkeys}}) {
            $v = shift @matches;
            $v =~ s/%([0-9a-fA-F]{2})/chr(hex($1))/ge if $v;
            $params{$k} = $v;
        }
        while (($k, $v) = each(%{$route->{defaults}})) {
            $params{$k} = $v unless defined($params{$k});
        }
        return \%params;
    }
    return undef;
}


sub generate
{
    my $self = shift;
    my $name = (scalar(@_) & 1) ? shift : undef;
    my %params = @_;
    my ($base, $k, $v, $route, $url);

    # If the controller and action are the same as the base set of parameters,
    # inherit any missing keys
    $base = delete $params{_base};
    if (!$name && _compare_targets($base, \%params)) {
        while (($k, $v) = each(%$base)) {
            $params{$k} = $v unless exists($params{$k});
        }
    }

    # A controller and action are always required
    $params{controller} ||= 'content';
    $params{action} ||= 'index';

    # Expand any objects supplied as route parameters
    foreach (keys(%params)) {
        next unless ref($v = $params{$_});
        $v = $v->route_param;
        if (ref $v) {
            delete $params{$_};
            %params = (%params, %$v);
        } else {
            $params{$_} = $v;
        }
    }

    # Find the appropriate route
    if ($name) {
        # Quick: find the named route
        $route = $self->{named}{$name};
    } else {
        # Slower: scan routes for the first valid parameter match
        foreach my $r (@{$self->{routes}}) {
            next unless _compare_targets($r->{target}, \%params);
            next unless _validate_route_params($r, \%params);
            $route = $r;
            last;
        }
    }
    return undef unless $route;

    # Fill in missing parameters with the defaults from the matched route
    while (($k, $v) = each(%{$route->{defaults}})) {
        $params{$k} = $v unless exists($params{$k});
    }

    return _insert_route_params($route, \%params);
}


sub names
{
    return [sort keys %{$_[0]->{named}}];
}


sub _tidy_path
{
    my ($path) = @_;
    # 1. Remove trailing slashes
    $path =~ s/\/+$//;
    # 2. Remove leading slashes before a /
    $path =~ s/(?<!:)\/+(?=\/)//g;
    # 3. Ensure leading '/' unless it's a full URL
    $path = '/' . $path if ($path !~ /^\// && $path !~ /^\w+:\/\//);
    return $path;
}

# Does this route match these params?
# The route matches if the controller and the action match.
# controller/action are matched as follows: it matches if it's specified in
# the route, and the corresponding param is either missing, or the same as
# that on the route, or "*".

sub _compare_targets
{
    my ($base, $params) = @_;
    my ($key, $b, $p);

    foreach $key ('controller', 'action') {
        $b = $base->{$key};
        return 0 unless defined($b);
        $p = $params->{$key};
        return 0 if ($p && $p ne $b && $b ne '*');
    }
    return 1;
}

# Is this set of params OK for this route?
# Tests defaults and requirements (regexp).

sub _validate_route_params
{
    my ($route, $params) = @_;
    my ($key, $value, $regexp);
    my $requirements = $route->{requirements};
    my $defaults = $route->{defaults};

    foreach $key (@{$route->{matchkeys}}) {
        $value = $params->{$key};
        return 0 unless (defined($value) || exists($defaults->{$key}));
        $regexp = $requirements->{$key};
        return 0 if (defined($value) && $regexp && $value !~ /${regexp}/);
    }
    return 1;
}

sub _insert_route_params
{
    my ($route, $params) = @_;
    my ($filter, $fn, $url);

    # Run any requested filters on the parameters before inserting them
    $filter = $route->{options}{_filter} || [];
    $filter = [$filter] if (ref($filter) eq 'CODE');
    &$_($params) foreach (@$filter);

    return unless _validate_route_params($route, $params);

    # Insert the parameters into the URL
    $url = $route->{route};
    $url =~ s!([/.]?)\*\(?([a-z][a-z0-9_]*)\)?!_param($1, $params->{$2}, 1)!ge;
    $url =~ s!([/.]?):\(?([a-z][a-z0-9_]*)\)?!_param($1, $params->{$2}, 0)!ge;

    return $url;
}

sub _param
{
    my ($prefix, $s, $keep_slash) = @_;
    return '' if !defined($s);
    $s =~ s/([^A-Za-z0-9\/_.-])/sprintf('%%%02X', ord($1))/ge;
    $s =~ s/\//%2F/g unless $keep_slash;
    return $prefix . $s;
}


1;

__DATA__

=head1 NAME

Pinwheel::Mapper

=head1 SYNOPSIS

    use Pinwheel::Mapper;
    my $mapper = Pinwheel::Mapper->new();

    # TODO, add a meaningful synopsis

=head1 DESCRIPTION

Handles the routing functionality.

=head1 ROUTINES

=over 4

=item Mapper->new()

Constructor method.  Creates an empty mapper.

=item $mapper->reset()

Empties the mapper.

=item $mapper->connect([NAME,] PATH, OPTIONS)

TODO, document me.

The PATH is tidied.

'defaults' is removed from OPTIONS and kept (default: {})

'requirements' is removed from OPTIONS and kept (default: {})

If PATH is absolute (^\w+://) then the '_static=1' is added to OPTIONS.

target = {}

defaults are merged in from OPTIONS except for keys that begin with "_".
(Special keys seem to be: _static _base _filter).

Big scary regex parser replacement thing:

  find
        optional "/" or "." ("prefix")
        ":" or "*" (type)
        optional "("
        [a-z][a-z0-9_]* ("name")
        optional ")"

  push name onto matchkeys
  if name = "controller" or "action", set the appropriate 'target' to '*'
  if name "id", defaults{id}||=undef
  quotemeta prefix

  if type = "*":
        pattern = prefix."(.*)"
  elsif there is a "requirements" for name, use it (it's a regex):
        pattern = prefix."($requirement)"
  elsif prefix was "."
        pattern = prefix.'([^/.][^/]*)' # i.e. .repr is allowed to contain "."
  else
        pattern = prefix.'([^/.]+)' # i.e. /:foo is not allowed to contain "."
 
  make optional if a default exists for this name:
   (?:pattern)?



defaults{controller}||=content
defaults{action}||=index

target{controller} ||= defaults{controller}
target{action} ||= defaults{action}

Each value of 'requirements' is compiled into a regex: ^value$

Route hash is built.

Pushed to 'routes', unless _static
  (namely: absolute route, or _static=1 specifically passed in)

Stored in 'named', if a name was given.

=item $params = $mapper->match(PATH)

Finds the first route matching PATH.  Routes are tried in the order they were
added using C<$mapper-E<gt>connect>.

If a matching route is found, returns the parameters required.  If no matching
route is found, returns C<undef>.

TODO, document how matching works.

TODO, document what "the parameters required" means.

=item $url = $mapper->generate([NAME, ]PARAMS)

Create a url for a mapped controller.

TODO, what does that even mean?

NAME is TODO.  PARAMS is a list of name/value options.

_base
controller
action

requirements/defaults/regex

=item @names = @{ $mapper->names }

Returns the names of all named routes, sorted, as an array ref.

=back

=head1 AUTHOR

A&M Network Publishing <DLAMNetPub@bbc.co.uk>

=cut