#!/usr/bin/perl
#
# DW::Logic::LogItems
#
# Contains logic used to calculate what items should be showed on the reading page
# and other related functions.  Functions related to loading large numbers of entries
# in a complicated fashion should be in here.  General purpose entry functionality
# should be in LJ::Entry, etc.
#
# Authors:
#      Mark Smith <mark@dreamwidth.org>
#
# Copyright (c) 2009 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::Logic::LogItems;
use strict;

use Carp qw/ confess /;

# name: $u->watch_items
# des: Return watch list items for a given user, filter, and period.
# args: hash of items, key/values:
#           - remote
#           - itemshow
#           - skip
#           - friends (opt) friends rows loaded via [func[LJ::get_friends]]
#           - friends_u (opt) u objects of all friends loaded
#           - idsbycluster (opt) hashref to set clusterid key to [ [ journalid, itemid ]+ ]
#           - dateformat:  either "S2" for S2 code, or anything else for S1
#           - friendsoffriends: load friends of friends, not just friends
#           - security: (public|access) or a group number
#           - showtypes: /[PICNYF]/
#           - events_date: date to load events for ($u must have friendspage_per_day)
#           - content_filter: object of type DW::User::ContentFilters::Filter
# returns: Array of item hashrefs containing the same elements
sub watch_items {
    my ( $u, %args ) = @_;
    $u = LJ::want_user($u) or confess 'invalid user object';

    # bail very, very early for accounts that are too big for a reading page
    return () if $LJ::FORCE_EMPTY_SUBSCRIPTIONS{ $u->id };

    # not sure where best to do this, so we're doing it here: don't allow
    # content filters for community reading pages.
    delete $args{content_filter} unless $u->is_individual;

    # we only allow viewing protected content on your own reading page, if you
    # are viewing someone else's reading page, we assume you're logged out
    my $remote = LJ::want_user( delete $args{remote} ) || LJ::get_remote();
    $remote = undef if $remote && $remote->id != $u->id;

    # prepare some variables for later... many variables
    my @items    = ();
    my $itemshow = $args{itemshow} + 0;
    my $skip     = $args{skip} + 0;
    $skip = 0 if $skip < 0;
    my $getitems = $itemshow + $skip;

    # friendspage per day is allowed only for journals with the special cap 'friendspage_per_day'
    my $events_date = $args{events_date};
    $events_date = '' unless $remote && $u->can_use_daily_readpage;

    my $filter  = $args{content_filter};
    my $max_age = $LJ::MAX_FRIENDS_VIEW_AGE || 3600 * 24 * 14;                  # 2 week default.
    my $lastmax = $LJ::EndOfTime - ( $events_date || ( time() - $max_age ) );
    my $lastmax_cutoff = 0
        ; # if nonzero, never search for entries with rlogtime higher than this (set when cache in use)

    # given a hash of friends rows, strip out rows with invalid journaltype
    my $filter_journaltypes = sub {
        my ( $friends, $friends_u, $memcache_only, $valid_types ) = @_;
        return unless $friends && $friends_u;
        $valid_types ||= uc $args{showtypes} if defined $args{showtypes};

        # make (F)eeds an alias for s(Y)ndicated
        $valid_types =~ s/F/Y/g if defined $valid_types;

        # load u objects for all the given
        LJ::load_userids_multiple( [ map { $_, \$friends_u->{$_} } keys %$friends ],
            [$remote], $memcache_only );

        # delete u objects based on 'showtypes'
        foreach my $fid ( keys %$friends_u ) {
            my $fu = $friends_u->{$fid};
            if (   !$fu
                || !$fu->is_visible
                || $valid_types && index( uc($valid_types), $fu->{journaltype} ) == -1 )
            {
                delete $friends_u->{$fid};
                delete $friends->{$fid};
            }
        }

        # all args passed by reference
        return;
    };

    my @friends_buffer = ();
    my $fr_loaded      = 0;    # flag:  have we loaded friends?

    # normal friends mode
    my $get_next_friend = sub {

        # return one if we already have some loaded.
        return $friends_buffer[0] if @friends_buffer;
        return undef if $fr_loaded;

        # get all friends for this user and groupmask
        my $friends = $u->watch_list( community_okay => 1 );
        my %friends_u;

        # strip out people who aren't in the filter, if we have one
        if ($filter) {
            foreach my $fid ( keys %$friends ) {
                delete $friends->{$fid}
                    unless $filter->contains_userid($fid);
            }
        }

        # strip out rows with invalid journal types
        $filter_journaltypes->( $friends, \%friends_u );

        # get update times for all the friendids
        my $tu_opts = {};
        my $fcount  = scalar keys %$friends;
        if ( $LJ::SLOPPY_FRIENDS_THRESHOLD && $fcount > $LJ::SLOPPY_FRIENDS_THRESHOLD ) {
            $tu_opts->{memcache_only} = 1;
        }

        my $times =
            $events_date
            ? LJ::get_times_multi( $tu_opts, keys %$friends )
            : { updated => LJ::get_timeupdate_multi( $tu_opts, keys %$friends ) };
        my $timeupdate = $times->{updated};

        # now push a properly formatted @friends_buffer row
        foreach my $fid ( keys %$timeupdate ) {
            my $fu        = $friends_u{$fid};
            my $rupdate   = $LJ::EndOfTime - ( $timeupdate->{$fid} || 0 );
            my $clusterid = $fu->{'clusterid'};
            push @friends_buffer, [ $fid, $rupdate, $clusterid, $friends->{$fid}, $fu ];
        }

        @friends_buffer =
            sort { $a->[1] <=> $b->[1] }
            grep {
            ( $timeupdate->{ $_->[0] } || 0 ) >= $lastmax and    # reverse index
                (
                  $events_date
                ? $times->{created}->{ $_->[0] } < $events_date
                : 1
                )
            } @friends_buffer;

        # note that we've already loaded the friends
        $fr_loaded = 1;

        # return one if we just found some, else we're all
        # out and there's nobody else to load.
        return @friends_buffer ? $friends_buffer[0] : undef;
    };

    # memcached friends of friends mode
    $get_next_friend = sub {

        # return one if we already have some loaded.
        return $friends_buffer[0] if @friends_buffer;
        return undef if $fr_loaded;

        # get journal's friends
        my $friends = $u->watch_list or return undef;
        my %friends_u;

        # fill %allfriends with all friendids and cut $friends
        # down to only include those that match $filter
        my %allfriends = ();
        foreach my $fid ( keys %$friends ) {
            $allfriends{$fid}++;

            # if the person is in an active filter, allow them, else delete them
            next unless $filter && !$filter->contains_userid($fid);
            delete $friends->{$fid};
        }

        # strip out invalid friend journaltypes
        $filter_journaltypes->( $friends, \%friends_u, "memcache_only", "P" );

        # get update times for all the friendids
        my $f_tu = LJ::get_timeupdate_multi( { 'memcache_only' => 1 }, keys %$friends );

        # get friends of friends
        my $ffct     = 0;
        my %ffriends = ();
        foreach my $fid ( sort { $f_tu->{$b} <=> $f_tu->{$a} } keys %$friends ) {
            last if $ffct > 50;
            my $fu = $friends_u{$fid};
            my $ff = $fu->watch_list( community_okay => 1, memcache_only => 1 );
            my $ct = 0;
            while ( my $ffid = each %$ff ) {
                last if $ct > 100;
                next if $allfriends{$ffid} || $ffid == $u->id;
                $ffriends{$ffid} = $ff->{$ffid};
                $ct++;
            }
            $ffct++;
        }

        # strip out invalid friendsfriends journaltypes
        my %ffriends_u;
        $filter_journaltypes->( \%ffriends, \%ffriends_u, "memcache_only" );

        # get update times for all the friendids
        my $ff_tu = LJ::get_timeupdate_multi( { 'memcache_only' => 1 }, keys %ffriends );

        # build friends buffer
        foreach my $ffid ( sort { $ff_tu->{$b} <=> $ff_tu->{$a} } keys %$ff_tu ) {
            my $rupdate   = $LJ::EndOfTime - $ff_tu->{$ffid};
            my $clusterid = $ffriends_u{$ffid}->{'clusterid'};

            # since this is ff mode, we'll force colors to ffffff on 000000
            $ffriends{$ffid}->{'fgcolor'} = "#000000";
            $ffriends{$ffid}->{'bgcolor'} = "#ffffff";

            push @friends_buffer,
                [ $ffid, $rupdate, $clusterid, $ffriends{$ffid}, $ffriends_u{$ffid} ];
        }

        @friends_buffer = sort { $a->[1] <=> $b->[1] } @friends_buffer;

        # note that we've already loaded the friends
        $fr_loaded = 1;

        # return one if we just found some fine, else we're all
        # out and there's nobody else to load.
        return @friends_buffer ? $friends_buffer[0] : undef;

        }
        if $args{friendsoffriends} && @LJ::MEMCACHE_SERVERS;

    # friends of friends disabled w/o memcache
    confess 'friends of friends mode requires memcache'
        if $args{friendsoffriends} && !@LJ::MEMCACHE_SERVERS;

    my $loop      = 1;
    my $itemsleft = $getitems;    # even though we got a bunch, potentially, they could be old
    my $fr;

    while ( $loop && ( $fr = $get_next_friend->() ) ) {
        shift @friends_buffer;

        # load the next recent updating friend's recent items
        my $friendid = $fr->[0];

        $args{friends}->{$friendid}   = $fr->[3];    # friends row
        $args{friends_u}->{$friendid} = $fr->[4];    # friend u object

        my @newitems = LJ::get_log2_recent_user(
            {
                clusterid   => $fr->[2],
                userid      => $friendid,
                remote      => $remote,
                itemshow    => $itemsleft,
                filter      => $filter,
                notafter    => $lastmax,
                dateformat  => $args{dateformat},
                update      => $LJ::EndOfTime - $fr->[1],    # reverse back to normal
                events_date => $events_date,
                security    => $args{security},
            }
        );

        # stamp each with clusterid if from cluster, so ljviews and other
        # callers will know which items are old (no/0 clusterid) and which
        # are new
        $_->{clusterid} = $fr->[2] foreach @newitems;

        if (@newitems) {
            push @items, @newitems;

            $itemsleft--;

            my $evtime = sub { LJ::mysqldate_to_time( $_[0]->{eventtime} ) };

            # sort all the total items by rlogtime (recent at beginning).
            # if there's an in-second tie, the "newer" post is determined by
            # the later eventtime (if known/different from rlogtime), then by
            # the higher jitemid, which means nothing if the posts aren't in
            # the same journal, but means everything if they are (which happens
            # almost never for a human, but all the time for RSS feeds)
            @items = sort {
                       $a->{rlogtime} <=> $b->{rlogtime}
                    || $evtime->($b) <=> $evtime->($a)
                    || $b->{jitemid} <=> $a->{jitemid}
            } @items;

            # cut the list down to what we need.
            @items = splice( @items, 0, $getitems ) if ( @items > $getitems );
        }

        if ( @items == $getitems ) {
            $lastmax = $items[-1]->{'rlogtime'};
            $lastmax = $lastmax_cutoff if $lastmax_cutoff && $lastmax > $lastmax_cutoff;

            # stop looping if we know the next friend's newest entry
            # is greater (older) than the oldest one we've already
            # loaded.
            my $nextfr = $get_next_friend->();
            $loop = 0 if ( $nextfr && $nextfr->[1] > $lastmax );
        }
    }

    # remove skipped ones
    splice( @items, 0, $skip ) if $skip;

    # get items
    foreach (@items) {
        $args{owners}->{ $_->{'ownerid'} } = 1;
    }

    # return the itemids grouped by clusters, if callers wants it.
    if ( ref $args{idsbycluster} eq "HASH" ) {
        foreach (@items) {
            push @{ $args{idsbycluster}->{ $_->{'clusterid'} } },
                [ $_->{'ownerid'}, $_->{'itemid'} ];
        }
    }

    return @items;
}
*LJ::User::watch_items = \&watch_items;
*DW::User::watch_items = \&watch_items;

# name: $u->recent_items
# des: Returns journal entries for a given account.
# takes hash of options as arguments
#           -- err: scalar ref to return error code/msg in
#           -- remote: remote user's $u
#           -- clusterid: clusterid of userid
#           -- tagids: arrayref of tagids to return entries with
#           -- security: (public|access|private) or a group number
#           -- clustersource: if value 'slave', uses replicated databases
#           -- order: if 'logtime', sorts by logtime, not eventtime
#           -- friendsview: if true, sorts by logtime, not eventtime
#           -- notafter: upper bound inclusive for rlogtime/revttime (depending on sort mode),
#           defaults to no limit
#           -- skip: items to skip
#           -- itemshow: items to show
#           -- viewall: if set, no security is used.
#           -- dateformat: if "S2", uses S2's 'alldatepart' format.
#           -- itemids: optional arrayref onto which itemids should be pushed
#           -- posterid: optional, return (community) posts made by this poster only
# returns: array of hashrefs containing keys:
#          -- itemid (the jitemid)
#          -- posterid
#          -- security
#          -- alldatepart (in S1 or S2 fmt, depending on 'dateformat' req key)
#          -- system_alldatepart (same as above, but for the system time)
#          -- ownerid (if in 'friendsview' mode)
#          -- rlogtime (if in 'friendsview' mode)
sub recent_items {
    my ( $u, %args ) = @_;
    $u = LJ::want_user($u) or confess 'invalid user object';

    my $userid = $u->id;

    my @items = ();           # what we'll return
    my $err   = $args{err};

    my $remote   = LJ::want_user( delete $args{remote} );
    my $remoteid = $remote ? $remote->id : 0;

    my $max_hints = $LJ::MAX_SCROLLBACK_LASTN;    # temporary
    my $sort_key  = "revttime";

    my $clusterid = $args{'clusterid'} + 0;
    my @sources   = ("cluster$clusterid");
    if ( my $ab = $LJ::CLUSTER_PAIR_ACTIVE{$clusterid} ) {
        @sources = ("cluster${clusterid}${ab}");
    }
    unshift @sources, ( "cluster${clusterid}lite", "cluster${clusterid}slave" )
        if $args{'clustersource'} eq "slave";
    my $logdb = LJ::get_dbh(@sources);

    # community/friend views need to post by log time, not event time
    $sort_key = "rlogtime" if ( $args{'order'} eq "logtime"
        || $args{'friendsview'} );

    # 'notafter':
    #   the friends view doesn't want to load things that it knows it
    #   won't be able to use.  if this argument is zero or undefined,
    #   then we'll load everything less than or equal to 1 second from
    #   the end of time.  we don't include the last end of time second
    #   because that's what backdated entries are set to.  (so for one
    #   second at the end of time we'll have a flashback of all those
    #   backdated entries... but then the world explodes and everybody
    #   with 32 bit time_t structs dies)
    #
    # Unless we are not on a friends view, then want to use the actual end of time.
    my $notafter = $args{notafter} ? $args{notafter} + 0 : 0;
    $notafter ||= $args{friendsview} ? $LJ::EndOfTime - 1 : $LJ::EndOfTime;

    my $skip     = $args{skip}     ? $args{skip} + 0     : 0;
    my $itemshow = $args{itemshow} ? $args{itemshow} + 0 : 0;
    $itemshow = $max_hints if $itemshow > $max_hints;

    my $maxskip = $max_hints - $itemshow;
    if ( $skip < 0 )        { $skip = 0; }
    if ( $skip > $maxskip ) { $skip = $maxskip; }
    my $itemload = $itemshow + $skip;

    my $mask = 0;
    if ( $remote && ( $remote->is_person || $remote->is_identity ) && $remoteid != $userid ) {

        # if this is a community we're viewing, fake the mask to select on, as communities
        # no longer have masks to users
        if ( $u->is_community ) {
            $mask = $remote->member_of($u) ? 1 : 0;
        }
        else {
            $mask = $u->trustmask($remote);
        }
    }

    # decide what level of security the remote user can see
    my $secwhere = "";
    if (   $userid == $remoteid
        || ( $remote && $remote->can_manage($u) )
        || $args{'viewall'} )
    {
        # no extra where restrictions... user can see all their own stuff
        # community administrators can also see everything in their comms
        # alternatively, if 'viewall' opt flag is set, security is off.
    }
    elsif ($mask) {

        # can see public or things with them in the mask
        $secwhere = "AND (security='public' OR (security='usemask' AND allowmask & $mask != 0))";
    }
    else {
        # not a friend?  only see public.
        $secwhere = "AND security='public' ";
    }

    # because LJ::get_friend_items needs rlogtime for sorting.
    my $extra_sql = '';
    if ( $args{'friendsview'} ) {
        $extra_sql .= "journalid AS 'ownerid', rlogtime, ";
    }

    # if we need to get by tag, get an itemid list now
    my $jitemidwhere = '';
    if ( ref $args{tagids} eq 'ARRAY' && @{ $args{tagids} } ) {

        my $jitemids;

        # $args{tagmode} = $args{getargs}->{mode} eq 'and' ? 'and' : 'or';

        if ( $args{tagmode} eq 'and' ) {

            my $limit = $LJ::TAG_INTERSECTION;
            die "\$LJ::TAG_INTERSECTION not set!"
                unless $limit && $limit > 0;
            my $need = scalar @{ $args{tagids} };
            $#{ $args{tagids} } = $limit - 1 if $need > $limit;

            my $in  = join( ',', map { $_ + 0 } @{ $args{tagids} } );
            my $sth = $logdb->prepare(
                "SELECT jitemid, kwid FROM logtagsrecent WHERE journalid = ? AND kwid IN ($in)");
            $sth->execute($userid);
            die $logdb->errstr if $logdb->err;

            my %mix;
            while ( my $row = $sth->fetchrow_arrayref ) {
                my ($jitemid) = @$row;
                $mix{$jitemid}++;
            }

            foreach my $jitemid ( keys %mix ) {
                delete $mix{$jitemid} if $mix{$jitemid} < $need;
            }

            $jitemids = [ keys %mix ];

        }
        else {    # mode: 'or'
                  # select jitemids uniquely
            my $in = join( ',', map { $_ + 0 } @{ $args{tagids} } );
            $jitemids = $logdb->selectcol_arrayref(
                qq{
                    SELECT DISTINCT jitemid FROM logtagsrecent WHERE journalid = ? AND kwid IN ($in)
                }, undef, $userid
            );
            die $logdb->errstr if $logdb->err;
        }

        # set $jitemidwhere iff we have jitemids
        if (@$jitemids) {
            $jitemidwhere = " AND jitemid IN (" . join( ',', map { $_ + 0 } @$jitemids ) . ")";
        }
        else {
            # no items, so show no entries
            return ();
        }
    }

    # if we need to filter by security, build up the where clause for that too
    my $securitywhere = '';
    if ( $args{'security'} ) {
        my $security = $args{'security'};
        if ( ( $security eq "public" ) || ( $security eq "private" ) ) {
            $securitywhere = " AND security = \"$security\"";
        }
        elsif ( $security eq "access" ) {
            $securitywhere = " AND security = \"usemask\" AND allowmask = 1";
        }
        elsif ( $security =~ /^\d+$/ ) {
            $securitywhere =
                " AND security = \"usemask\" AND (allowmask & " . ( 1 << $security ) . ")";
        }
    }

    my $sql;

    my $dateformat = "%a %W %b %M %y %Y %c %m %e %d %D %p %i %l %h %k %H";
    if ( $args{'dateformat'} eq "S2" ) {
        $dateformat = "%Y %m %d %H %i %s %w";    # yyyy mm dd hh mm ss day_of_week
    }

    my ( $sql_limit, $sql_select ) = ( '', '' );
    if ( $args{'ymd'} ) {
        my ( $year, $month, $day );
        if ( $args{'ymd'} =~ m!^(\d\d\d\d)/(\d\d)/(\d\d)\b! ) {
            ( $year, $month, $day ) = ( $1, $2, $3 );

            # check
            if ( $year  !~ /^\d+$/ ) { $$err = "Corrupt or non-existant year.";  return (); }
            if ( $month !~ /^\d+$/ ) { $$err = "Corrupt or non-existant month."; return (); }
            if ( $day   !~ /^\d+$/ ) { $$err = "Corrupt or non-existant day.";   return (); }
            if ( $month < 1 || $month > 12 || int($month) != $month ) {
                $$err = "Invalid month.";
                return ();
            }
            if ( $year < 1970 || $year > 2038 || int($year) != $year ) {
                $$err = "Invalid year: $year";
                return ();
            }
            if ( $day < 1 || $day > 31 || int($day) != $day ) { $$err = "Invalid day."; return (); }
            if ( $day > LJ::days_in_month( $month, $year ) ) {
                $$err = "That month doesn't have that many days.";
                return ();
            }
        }
        else {
            $$err = "wrong date: " . $args{'ymd'};
            return ();
        }
        $sql_limit  = "LIMIT 2000";
        $sql_select = "AND year=$year AND month=$month AND day=$day";
        $extra_sql .= "allowmask, ";
    }
    else {
        $sql_limit  = "LIMIT $skip,$itemshow";
        $sql_select = "AND $sort_key <= $notafter";
    }

    my $posterwhere;
    if ( $args{posterid} && $args{posterid} =~ /^(\d+)$/ ) {
        $posterwhere = " AND posterid=$1";
    }
    else {
        $posterwhere = "";
    }

    $sql = qq{
        SELECT jitemid AS 'itemid', posterid, security, $extra_sql
               DATE_FORMAT(eventtime, "$dateformat") AS 'alldatepart', anum,
               DATE_FORMAT(logtime, "$dateformat") AS 'system_alldatepart',
               allowmask, eventtime, logtime
        FROM log2 USE INDEX ($sort_key)
        WHERE journalid=$userid $sql_select $secwhere $jitemidwhere $securitywhere $posterwhere
        ORDER BY journalid, $sort_key
        $sql_limit
    };

    unless ($logdb) {
        $$err = "nodb" if ref $err eq "SCALAR";
        return ();
    }

    my $sth = $logdb->prepare($sql);
    $sth->execute;
    if ( $logdb->err ) { die $logdb->errstr; }

    # keep track of the last alldatepart, and a per-minute buffer
    my $last_time;
    my @buf;
    my $flush = sub {
        return unless @buf;
        push @items, sort { $b->{itemid} <=> $a->{itemid} } @buf;
        @buf = ();
    };

    while ( my $li = $sth->fetchrow_hashref ) {
        push @{ $args{'itemids'} }, $li->{'itemid'};

        my $sortdate = {
            rlogtime => 'system_alldatepart',
            revttime => 'alldatepart'
        }->{$sort_key};

        $flush->()
            unless defined $last_time
            && $li->{$sortdate} eq $last_time;
        push @buf, $li;
        $last_time = $li->{$sortdate};

        # construct an LJ::Entry singleton
        my $entry = LJ::Entry->new( $userid, jitemid => $li->{itemid} );
        $entry->absorb_row(%$li);
    }
    $flush->();

    return @items;
}
*LJ::User::recent_items = \&recent_items;
*DW::User::recent_items = \&recent_items;

# name: $u->active_entries
# des: Returns 10 last active entries for an account
# returns: array of itemids
sub active_entries {
    my ($u) = @_;
    my $uid = $u->userid;

    # check memcache first
    my $activeentries = LJ::MemCache::get( [ $uid, "activeentries:$uid" ] );
    return @$activeentries if $activeentries;

    # get latest 10 entries that were commented on - we will see whether $remote can view them later
    # disregard screened and deleted comments when ordering

    # NOTE: we have to force the index because MySQL's optimizer gets this wrong. we know that our
    # data is going to be near the top.
    my $entryids = $u->selectcol_arrayref(
        q{SELECT DISTINCT nodeid FROM talk2 FORCE INDEX (PRIMARY)
          WHERE journalid = ? AND state NOT IN ('D', 'S')
          ORDER BY jtalkid DESC LIMIT 10},
        undef, $u->id
    );
    die $u->errstr if $u->err;
    return unless $entryids && @$entryids;

    # memcache this data in the form: activeentries:journalid
    LJ::MemCache::set( [ $uid, "activeentries:$uid" ], \@$entryids );

    # return. we check whether the user viewing is allowed to view these entries later
    return @$entryids;
}

*LJ::User::active_entries = \&active_entries;
*DW::User::active_entries = \&active_entries;

1;
