# Movable Type (r) Open Source (C) 2001-2009 Six Apart, Ltd.
# This program is distributed under the terms of the
# GNU General Public License, version 2.
#
# $Id: TagSearch.pm 4196 2009-09-04 07:46:50Z takayama $

package MT::App::Search::TagSearch;

use strict;
use MT::Util qw( epoch2ts offset_time_list );
use HTTP::Date qw( str2time );

sub process {
    my $app = shift;
    return $app->errtrans('TagSearch works with MT::App::Search.')
        unless $app->isa('MT::App::Search');

    my ( $count, $out ) = $app->check_cache();
    if ( defined $out ) {
        $app->run_callbacks( 'search_cache_hit', $count, $out );
        return $out;
    }

    my ( $terms, $args ) = &search_terms( $app );
    return $app->error($app->errstr) if $app->errstr;

    $count = 0;
    my $iter;
    if ( $terms && $args ) {
        ( $count, $iter ) = $app->execute( $terms, $args );
        return $app->error($app->errstr) unless $iter;

        $app->run_callbacks( 'search_post_execute', $app, \$count, \$iter );
    }

    my $format = $app->param('format') || q();
    my $method = "render";
    if ( $format ) {
        $method .= $format if $app->can( $method . $format );
    }
    elsif ( my $tmpl_name = $app->param('Template') ) {
        $method .= $tmpl_name if $app->can( $method . $tmpl_name );
    }

    $out = $app->$method( $count, $iter );

    my $result;
    if (ref($out) && ($out->isa('MT::Template'))) {
        defined( $result = $out->build() )
            or return $app->error($out->errstr);
    }
    else {
        $result = $out;
    }

    $app->run_callbacks( 'search_post_render', $app, $count, $result );
    $result;
}

sub _process_lucene_query {
    my ($search_string) = @_;

    require Lucene::QueryParser;
    my $lucene_struct = Lucene::QueryParser::parse_query( $search_string );

    my @or_tags;
    for my $term ( @$lucene_struct ) {
        next if exists $term->{field};
        next if 'SUBQUERY' eq $term->{query};

        if ( ( 'TERM' eq $term->{query} ) || ( 'PHRASE' eq $term->{query} ) ){
            if ( 'OR' eq $term->{conj} ) {
                if ( my $prev = pop @or_tags ) {
                    push @or_tags, $prev . ',' . $term->{term};
                    next;
                }
            }
            push @or_tags, $term->{term};
        }
    }
    @or_tags;
}

sub add_join {
    my ( $class, $terms, $def_args, $depth, $tag_ids ) = @_;

    my $alias = $class->datasource . '_' . $depth;
    my $j_term = "= $alias.objecttag_object_id";
    $depth++;
    my $this_alias = $class->datasource . '_' . $depth;
    my %args;
    my $tag_id = shift @$tag_ids;
    if ( $tag_id ) {
        my $j_arg = &add_join( $class, $terms, $def_args, $depth, $tag_ids );
        $args{'join'} = $j_arg if $j_arg && @$j_arg;
    }
    if ( $tag_id ) {
        return $class->join_on( undef,
          {
            object_id => \$j_term,
            tag_id => $tag_id,
            %$terms
          },
          {
            %args,
            %$def_args,
            'alias' => $this_alias
          }
        );
    }
    undef;
}

sub search_terms {
    my $app = shift;
    my $q = $app->param;

    my $offset = $q->param('startIndex') || $q->param('offset') || 0;
    my $limit = $q->param('count') || $q->param('limit');
    my $max = $app->{searchparam}{SearchMaxResults};
    $limit = $max if !$limit || ( $limit - $offset > $max );

    my $tag_class = $app->model('tag');
    my $search_string = $q->param('tag') || $q->param('search');
    $app->{search_string} = $search_string;

    my @or_tag_names;
    if ( ( $search_string =~ /\s+OR\s+/ )
      || ( $search_string =~ /\s+AND\s+/ )
      || ( $search_string =~ /\s*"\s*/ ) ) {
        @or_tag_names = &_process_lucene_query( $search_string );
    }
    else {
        my $counter = sub {
            my $tmp = $_[0];
            my $count = $tmp =~ s/,/,/g;
            $count;
        };

        # plus to split intersection, comma to split addition 
        @or_tag_names = split /\+\s*/, $search_string;
        # sort by the number of tags in each section (smallest first)
        @or_tag_names = sort { $counter->($a) cmp $counter->($b) } @or_tag_names;
    }

    my @or_tags;
    my $terms = {
        $app->config->SearchPrivateTags ? () : ( is_private => '0' )
    };
    foreach my $or_tag_name ( @or_tag_names ) {
        my %tags = map { $_ => 1, $tag_class->normalize($_) => 1 } split(/,/, $or_tag_name);
        $terms->{name} = [ keys %tags ];
        my @tags = $tag_class->load($terms);
        my @tmp;
        foreach my $tag (@tags) {
            push @tmp, $tag->id;
            my @more = $tag_class->load({ n8d_id => $tag->n8d_id ? $tag->n8d_id : $tag->id });
            push @tmp, $_->id foreach @more;
        }
        push @or_tags, \@tmp if @tmp;
    }
    return ( undef, undef ) unless @or_tags;

    my $ot_class = $app->model('objecttag');
    my $class = $app->model( $app->{searchparam}{Type} )
        or return $app->error($app->errstr);

    my $params = $app->registry( 'default', 'types', $app->{searchparam}{Type} );
    my %terms = exists( $params->{terms} )
          ? %{ $params->{terms} }
          : ();
    delete $terms{'plugin'}; #FIXME: why is this in here?

    if ( exists $app->{searchparam}{IncludeBlogs} ) {
        $terms{blog_id} = $app->{searchparam}{IncludeBlogs};
    }

    my $depth = 1;
    my $alias = $ot_class->datasource . '_' . $depth;

    my $or_tag = shift @or_tags;
    my $join_on_arg;
    if ( @or_tags ) {
        $join_on_arg = &add_join(
            $ot_class,
            { object_datasource => $class->datasource },
            { },
            $depth,
            \@or_tags
        );
    }
    #TODO: what if pk is from multiple cols?
    my $pk = $class->datasource . '_' . $class->properties->{primary_key};
    my $join_on = $ot_class->join_on( undef,
        { tag_id => $or_tag,
          object_datasource => $class->datasource,
          object_id => \"= $pk"
        },
        { alias => $alias,
          $join_on_arg && @$join_on_arg ? ( 'join' => $join_on_arg ) : ()
        }
    );

    my $desc = 'descend' eq $app->{searchparam}{SearchResultDisplay} ? 'DESC' : 'ASC';
    my @sort;
    my $sort = $params->{'sort'};
    if ( $sort !~ /\w+\!$/ && $app->{searchparam}{SearchSortBy} ) {
        my $sort_by = $app->{searchparam}{SearchSortBy};
        $sort_by =~ s/[^\w\-\.\,]+//g;
        if ( $sort_by ) {
            my @sort_bys = split ',', $sort_by;
            foreach my $key ( @sort_bys ) {
                push @sort, {
                    desc   => $desc,
                    column => $key
                };
            }
        }
    }
    push @sort, {
        desc   => $desc,
        column => $sort
    };

    my %args = (
      'join' => $join_on,
      $limit  ? ( 'limit' => $limit ) : (),
      $offset ? ( 'offset' => $offset ) : (),
      @sort   ? ( 'sort' => \@sort ) : (),
    );

    my $blog_class = $app->model('blog');
    # Override SearchCutoff if If-Modified-Since header is present
    if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->param('Template') eq 'feed') {
        my $tz_offset = 15;  # Start with maximum possible offset to UTC
        my $blog_selected;
        my $iter;
        if ($app->{searchparam}{IncludeBlogs}) {
            $iter = $blog_class->load_iter({ id => $app->{searchparam}{IncludeBlogs} });
        } else {
            $iter = $blog_class->load_iter;
        }
        while (my $blog = $iter->()) {
            my $blog_offset = $blog->server_offset ?
                $blog->server_offset : 0;
            if ($blog_offset < $tz_offset) {
                $tz_offset = $blog_offset;
                $blog_selected = $blog;
            }
        }
        $mod_since = epoch2ts($blog_selected, str2time($mod_since));
        if ( ( 'entry' eq $app->{searchparam}{Type} )
          || ( 'page'  eq $app->{searchparam}{Type} ) ) {
            $terms{authored_on} = [ $mod_since ];
            $args{range} = { authored_on => 1 };
        }
        else {
            $terms{created_on} = [ $mod_since ];
            $args{range} = { created_on => 1 };
        }
    }

    ( \%terms, \%args );
}

1;
__END__

=head1 NAME

MT::App::Search::TagSearch

=head1 SYNTAX

TagSearch allows the following syntax in url-encoded "tag" parameter.

"MovableType,TypePad,Vox" matches objects which has either MovableType,
TypePad, or Vox tag.

"MovableType+TypePad+Vox" matches objects which has all three tags.

"TypePad OR Vox AND "Movable Type"" matches objects which has
either TypePad or Vox tag *and* "Movable Type" tag.

=head1 AUTHOR & COPYRIGHT

Please see L<MT/AUTHOR & COPYRIGHT>.

=cut
