# This code was forked from the LiveJournal project owned and operated
# by Live Journal, Inc. The code has been modified and expanded by
# Dreamwidth Studios, LLC. These files were originally licensed under
# the terms of the license supplied by Live Journal, Inc, which can
# currently be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.

package LJ::Talk;

use strict;
use v5.10;
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);

use Digest::MD5;
use MIME::Words;
use MIME::Lite;
use Carp qw/ croak /;
use DW::Task::SphinxCopier;

use DW::Captcha;
use DW::EmailPost::Comment;
use DW::Formats;
use LJ::Utils qw(rand_chars);
use LJ::Comment;
use LJ::Event::JournalNewComment;
use LJ::Event::JournalNewComment::Edited;
use LJ::Global::Constants;
use LJ::JSON;
use LJ::OpenID;
use LJ::S2;

# dataversion for rate limit logging
our $RATE_DATAVER = "1";

my %subjecticons;

# Returns a hashref of the following form:
# {
#     types => ['sm', 'md'],
#     lists => {
#         sm => [
#             { img => "sm01_smiley.gif", id => "sm01", w => 15, h => 15, alt => "Smiley" },
#             ...
#         ],
#         md => [
#             { img => "md01_alien.gif", id => "md01", w => 32, h => 32, alt => "Smiling Alien" },
#             ...
#         ]
#     },
#     pic => { # flat index for convenience
#         sm01 => { img => "sm01_smiley.gif", id => "sm01", w => 15, h => 15, alt => "Smiley" },
#         ...
#     }
# }
sub get_subjecticons {
    unless ( keys %subjecticons ) {
        $subjecticons{'types'} = [ 'sm', 'md' ];
        $subjecticons{'lists'}->{'md'} = [
            { img => "md01_alien.gif",       w => 32, h => 32, alt => "Smiling Alien" },
            { img => "md02_skull.gif",       w => 32, h => 32, alt => "Skull and Crossbones" },
            { img => "md05_sick.gif",        w => 25, h => 25, alt => "Sick Face" },
            { img => "md06_radioactive.gif", w => 20, h => 20, alt => "Radioactive Symbol" },
            { img => "md07_cool.gif",        w => 20, h => 20, alt => "Cool Smiley" },
            { img => "md08_bulb.gif",        w => 17, h => 23, alt => "Lightbulb" },
            { img => "md09_thumbdown.gif",   w => 25, h => 19, alt => "Red Thumbs Down" },
            { img => "md10_thumbup.gif",     w => 25, h => 19, alt => "Green Thumbs Up" }
        ];
        $subjecticons{'lists'}->{'sm'} = [
            { img => "sm01_smiley.gif", w => 15, h => 15, alt => "Smiley" },
            { img => "sm02_wink.gif",   w => 15, h => 15, alt => "Winking Smiley" },
            { img => "sm03_blush.gif",  w => 15, h => 15, alt => "Blushing Smiley" },
            { img => "sm04_shock.gif",  w => 15, h => 15, alt => "Shocked Smiley" },
            { img => "sm05_sad.gif",    w => 15, h => 15, alt => "Sad Smiley" },
            { img => "sm06_angry.gif",  w => 15, h => 15, alt => "Angry Smiley" },
            { img => "sm07_check.gif",  w => 15, h => 15, alt => "Checkmark" },
            { img => "sm08_star.gif",   w => 20, h => 18, alt => "Gold Star" },
            { img => "sm09_mail.gif",   w => 14, h => 10, alt => "Envelope" },
            { img => "sm10_eyes.gif",   w => 24, h => 12, alt => "Shifty Eyes" }
        ];

        # assemble ->{'id'} portion of hash.  the part of the imagename before the _
        foreach ( keys %{ $subjecticons{'lists'} } ) {
            foreach my $pic ( @{ $subjecticons{'lists'}->{$_} } ) {
                next unless ( $pic->{'img'} =~ /^(\D{2}\d{2})\_.+$/ );
                $subjecticons{'pic'}->{$1} = $pic;
                $pic->{'id'} = $1;
            }
        }
        $subjecticons{'pic'}->{'none'} = {
            img => "none.gif",
            id  => "none",
            w   => 15,
            h   => 15,
            alt => "No Subject Icon Selected"
        };
    }

    return \%subjecticons;
}

# Returns talkurl with GET args added (don't pass #anchors to this :-)
sub talkargs {
    my $talkurl = shift;
    my $args    = join( "&", grep { $_ } @_ );
    my $sep     = '';
    $sep = ( $talkurl =~ /\?/ ? "&" : "?" ) if $args;
    return "$talkurl$sep$args";
}

# LJ::Talk::get_subjecticon_by_id(id)
# Args: A subjecticon ID string (like 'none' or 'sm09').
# Returns: A subjecticon hashref suitable for LJ::Talk::print_subjecticon, or
#          undef if the ID is empty/invalid.
sub get_subjecticon_by_id {
    my $id           = shift;
    my $subjecticons = LJ::Talk::get_subjecticons();
    return $subjecticons->{'pic'}->{$id};
}

# LJ::Talk::print_subjecticon(subjecticon_hashref)
# Args: A hashref that represents a subjecticon, and optionally a string of
#       extra HTML attributes.
# Returns: An image tag for the requested subjecticon, or an empty string if the
#          subjecticon hashref missing.
# Subjecticon hashrefs usually come from get_subjecticons.
sub print_subjecticon {    # expects a subjecticon ref
    my ( $p, $extra ) = @_;
    return '' unless ref $p eq 'HASH';
    return
qq{<img src="$LJ::IMGPREFIX/talk/$p->{img}" border="0" width="$p->{w}" height="$p->{h}" alt="$p->{alt}" valign="middle" $extra />};
}

# LJ::Talk::print_subjecticon_by_id(id)
# Args: A subjecticon ID string (like 'none' or 'sm09'), and optionally a string
#       of extra HTML attributes.
# Returns: An image tag for the requested subjecticon, or an empty string if the
#          ID is empty or invalid.
sub print_subjecticon_by_id {
    my ( $id, $extra ) = @_;
    return print_subjecticon( get_subjecticon_by_id($id), $extra );
}

sub link_bar {
    my $opts = shift;
    my ( $u, $up, $remote, $headref, $itemid ) =
        map { $opts->{$_} } qw(u up remote headref itemid);

    # we want user objects, so make sure they are
    ( $u, $up, $remote ) = map { LJ::want_user($_) } ( $u, $up, $remote );

    my $mlink = sub {
        my ( $url, $piccode ) = @_;
        return (
            "<a href=\"$url\">" . LJ::img( $piccode, "", { 'align' => 'absmiddle' } ) . "</a>" );
    };

    my $jarg    = "journal=$u->{'user'}&";
    my $jargent = "journal=$u->{'user'}&amp;";

    my $entry = LJ::Entry->new( $u, ditemid => $itemid );

    # << Previous
    my @linkele;
    my $prevlink = LJ::create_url(
        "/go",
        host          => $LJ::DOMAIN_WEB,
        viewing_style => 1,
        args          => {
            journal => $u->user,
            itemid  => $itemid,
            dir     => "prev",
        }
    );
    push @linkele, $mlink->( $prevlink, "prev_entry" );
    $$headref .= "<link href='$prevlink' rel='Previous' />\n";

    # memories
    if ( LJ::is_enabled('memories') ) {
        push @linkele, $mlink->( "$LJ::SITEROOT/tools/memadd?${jargent}itemid=$itemid", "memadd" );
    }

    # edit entry - if we have a remote, and that person can manage
    # the account in question, OR, they posted the entry, and have
    # access to the community in question
    if (
        defined $remote
        && ( $remote->can_manage($u)
            || ( $remote->equals($up) && $up->can_post_to($u) ) )
        )
    {
        push @linkele,
            $mlink->( "$LJ::SITEROOT/editjournal?${jargent}itemid=$itemid", "editentry" );
    }

    # edit tags
    if ( LJ::is_enabled('tags') ) {
        if ( defined $remote && LJ::Tags::can_add_entry_tags( $remote, $entry ) ) {
            push @linkele,
                $mlink->( "$LJ::SITEROOT/edittags?${jargent}itemid=$itemid", "edittags" );
        }
    }

    if ( LJ::is_enabled('tellafriend') ) {
        push @linkele,
            $mlink->( "$LJ::SITEROOT/tools/tellafriend?${jargent}itemid=$itemid", "tellfriend" )
            if ( $entry->can_tellafriend($remote) );
    }

    if ( $remote && $remote->can_use_esn ) {
        my $img_key = $remote->has_subscription(
            journal        => $u,
            event          => "JournalNewComment",
            arg1           => $itemid,
            require_active => 1
        ) ? "track_active" : "track";
        push @linkele,
            $mlink->( "$LJ::SITEROOT/manage/tracking/entry?${jargent}itemid=$itemid", $img_key );
    }

    ## >>> Next
    my $nextlink = LJ::create_url(
        "/go",
        host          => $LJ::DOMAIN_WEB,
        viewing_style => 1,
        args          => {
            journal => $u->user,
            itemid  => $itemid,
            dir     => "next",
        }
    );
    push @linkele, $mlink->( "$nextlink", "next_entry" );
    $$headref .= "<link href='$nextlink' rel='Next' />\n";

    my $ret;
    if (@linkele) {
        $ret =
              qq{<div class="action-box"><ul class="nostyle inner"><li>}
            . join( "</li><li>", @linkele )
            . "</li></ul></div><div class='clear-floats'></div>";
    }
    return $ret;
}

# <LJFUNC>
# name: LJ::Talk::can_delete
# des: Determines if a user can delete a comment or entry: You can
#       delete anything you've posted.  You can delete anything posted in something
#       you own (i.e. a comment in your journal, a comment to an entry you made in
#       a community).  You can also delete any item in an account you have the
#       "A"dministration edge for.
# args: remote, u, up, userpost
# des-remote: User object we're checking access of.  From [func[LJ::get_remote]].
# des-u: Username or object of the account the thing is located in.
# des-up: Username or object of person who owns the parent of the thing.  (I.e. the poster
#           of the entry a comment is in.)
# des-userpost: Username (<strong>not</strong> object) of person who posted the item.
# returns: Boolean indicating whether remote is allowed to delete the thing
#           specified by the other options.
# </LJFUNC>
sub can_delete {
    my ( $remote, $u, $up, $userpost ) = @_;    # remote, journal, posting user, commenting user
    $userpost ||= "";

    return 0 unless LJ::isu($remote);
    return 1
        if $remote->user eq $userpost
        || $remote->user eq ( ref $u ? $u->user : $u )
        || LJ::Talk::can_screen(@_);
    return 0;
}

sub can_screen {
    my ( $remote, $u, $up, $userpost ) = @_;    # remote, journal, posting user, commenting user
    return 0 unless LJ::isu($remote);
    return 1
        if $remote->user eq ( ref $up  ? $up->user : $up )
        || $remote->can_manage( ref $u ? $u        : LJ::load_user($u) );
    return 0;
}

sub can_unscreen {
    return LJ::Talk::can_screen(@_);
}

sub can_freeze {
    return LJ::Talk::can_screen(@_);
}

sub can_unfreeze {
    return LJ::Talk::can_unscreen(@_);
}

# <LJFUNC>
# name: LJ::Talk::screening_level
# des: Determines the screening level of a particular post given the relevant information.
# args: journalu, jitemid
# des-journalu: User object of the journal the post is in.
# des-jitemid: Itemid of the post.
# returns: Single character that indicates the screening level.  Undef means don't screen
#          anything, 'A' means screen All, 'R' means screen Anonymous (no-remotes), 'F' means
#          screen non-friends.
# </LJFUNC>
sub screening_level {
    my ( $journalu, $jitemid ) = @_;
    die 'LJ::screening_level needs a user object.' unless ref $journalu;
    $jitemid += 0;
    die 'LJ::screening_level passed invalid jitemid.' unless $jitemid;

    # load the logprops for this entry
    my %props;
    LJ::load_log_props2( $journalu->{userid}, [$jitemid], \%props );

    # determine if userprop was overriden
    my $val = $props{$jitemid}{opt_screening} || '';
    return if $val eq 'N';    # N means None, so return undef
    return $val if $val;

    # now return userprop, as it's our last chance
    my $userprop = $journalu->prop('opt_whoscreened');
    return $userprop && $userprop eq 'N' ? undef : $userprop;
}

sub update_commentalter {
    my ( $u, $itemid ) = @_;
    LJ::set_logprop( $u, $itemid, { 'commentalter' => time() } );
}

# <LJFUNC>
# name: LJ::Talk::get_comments_in_thread
# class: web
# des: Gets a list of comment ids that are contained within a thread, including the
#      comment at the top of the thread.  You can also limit this to only return comments
#      of a certain state.
# args: u, jitemid, jtalkid, onlystate, screenedref
# des-u: user object of user to get comments from
# des-jitemid: journal itemid to get comments from
# des-jtalkid: journal talkid of comment to use as top of tree
# des-onlystate: if specified, return only comments of this state (e.g. A, F, S...)
# des-screenedref: if provided and an array reference, will push on a list of comment
#                   ids that are being returned and are screened (mostly for use in deletion so you can
#                   unscreen the comments)
# returns: undef on error, array reference of jtalkids on success
# </LJFUNC>
sub get_comments_in_thread {
    my ( $u, $jitemid, $jtalkid, $onlystate, $screened_ref ) = @_;
    $u = LJ::want_user($u);
    $jitemid += 0;
    $jtalkid += 0;
    $onlystate = uc $onlystate;
    return undef
        unless $u
        && $jitemid
        && $jtalkid
        && ( !$onlystate || $onlystate =~ /^\w$/ );

    # get all comments to post
    my $comments = LJ::Talk::get_talk_data( $u, 'L', $jitemid ) || {};

    # see if our comment exists
    return undef unless $comments->{$jtalkid};

    # create relationship hashref and count screened comments in post
    my %parentids;
    $parentids{$_} = $comments->{$_}{parenttalkid} foreach keys %$comments;

    # now walk and find what to update
    my %to_act;
    foreach my $id ( keys %$comments ) {
        my $act  = ( $id == $jtalkid );
        my $walk = $id;
        while ( $parentids{$walk} ) {
            if ( $parentids{$walk} == $jtalkid ) {

                # we hit the one we want to act on
                $act = 1;
                last;
            }
            last if $parentids{$walk} == $walk;

            # no match, so move up a level
            $walk = $parentids{$walk};
        }

        # set it as being acted on
        $to_act{$id} = 1 if $act && ( !$onlystate || $comments->{$id}{state} eq $onlystate );

        # push it onto the list of screened comments? (if the caller is doing a delete, they need
        # a list of screened comments in order to unscreen them)
        push @$screened_ref, $id if ref $screened_ref &&    # if they gave us a ref
            $to_act{$id} &&                                 # and we're acting on this comment
            $comments->{$id}{state} eq 'S';                 # and this is a screened comment
    }

    # return list from %to_act
    return [ keys %to_act ];
}

# <LJFUNC>
# name: LJ::Talk::delete_thread
# class: web
# des: Deletes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to delete thread from.
# des-jitemid: Journal itemid of item to delete comments from.
# des-jtalkid: Journal talkid of comment at top of thread to delete.
# returns: 1 on success; undef on error
# </LJFUNC>
sub delete_thread {
    my ( $u, $jitemid, $jtalkid ) = @_;

    # get comments and delete 'em
    my @screened;
    my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, undef, \@screened );
    LJ::Talk::unscreen_comment( $u, $jitemid, @screened ) if @screened;    # if needed only!
    my $num = LJ::delete_comments( $u, "L", $jitemid, @$ids );
    LJ::replycount_do( $u, $jitemid, "decr", $num );
    LJ::Talk::update_commentalter( $u, $jitemid );
    return 1;
}

# <LJFUNC>
# name: LJ::Talk::delete_comment
# class: web
# des: Deletes a single comment.
# args: u, jitemid, jtalkid, state?
# des-u: Userid or user object to delete comment from.
# des-jitemid: Journal itemid of item to delete comment from.
# des-jtalkid: Journal talkid of the comment to delete.
# des-state: Optional. If you know it, provide the state
#            of the comment being deleted, else we load it.
# returns: 1 on success; undef on error
# </LJFUNC>
sub delete_comment {
    my ( $u, $jitemid, $jtalkid, $state ) = @_;
    return undef unless $u && $jitemid && $jtalkid;

    unless ($state) {
        my $td = LJ::Talk::get_talk_data( $u, 'L', $jitemid );
        return undef unless $td;

        $state = $td->{$jtalkid}->{state};
    }
    return undef unless $state;

    # if it's screened, unscreen it first to properly adjust logprops
    LJ::Talk::unscreen_comment( $u, $jitemid, $jtalkid )
        if $state eq 'S';

    # now do the deletion
    my $num = LJ::delete_comments( $u, "L", $jitemid, $jtalkid );
    LJ::replycount_do( $u, $jitemid, "decr", $num );
    LJ::Talk::update_commentalter( $u, $jitemid );

    # done
    return 1;
}

# <LJFUNC>
# name: LJ::Talk::freeze_thread
# class: web
# des: Freezes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to freeze thread from.
# des-jitemid: Journal itemid of item to freeze comments from.
# des-jtalkid: Journal talkid of comment at top of thread to freeze.
# returns: 1 on success; undef on error
# </LJFUNC>
sub freeze_thread {
    my ( $u, $jitemid, $jtalkid ) = @_;

    # now we need to update the states
    my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, 'A' );
    LJ::Talk::freeze_comments( $u, "L", $jitemid, 0, $ids );
    return 1;
}

# <LJFUNC>
# name: LJ::Talk::unfreeze_thread
# class: web
# des: unfreezes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to unfreeze thread from.
# des-jitemid: Journal itemid of item to unfreeze comments from.
# des-jtalkid: Journal talkid of comment at top of thread to unfreeze.
# returns: 1 on success; undef on error
# </LJFUNC>
sub unfreeze_thread {
    my ( $u, $jitemid, $jtalkid ) = @_;

    # now we need to update the states
    my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, 'F' );
    LJ::Talk::freeze_comments( $u, "L", $jitemid, 1, $ids );
    return 1;
}

# <LJFUNC>
# name: LJ::Talk::freeze_comments
# class: web
# des: Freezes comments.  This is the internal helper function called by
#      freeze_thread/unfreeze_thread.  Use those if you wish to freeze or
#      unfreeze a thread.  This function just freezes specific comments.
# args: u, nodetype, nodeid, unfreeze, ids
# des-u: Userid or object of user to manipulate comments in.
# des-nodetype: Nodetype of the thing containing the specified ids.  Typically "L".
# des-nodeid: Id of the node to manipulate comments from.
# des-unfreeze: If 1, unfreeze instead of freeze.
# des-ids: Array reference containing jtalkids to manipulate.
# returns: 1 on success; undef on error
# </LJFUNC>
sub freeze_comments {
    my ( $u, $nodetype, $nodeid, $unfreeze, $ids ) = @_;
    $u = LJ::want_user($u);
    $nodeid += 0;
    $unfreeze = $unfreeze ? 1 : 0;
    return undef unless LJ::isu($u) && $nodetype =~ /^\w$/ && $nodeid && @$ids;

    # get database and quote things
    return undef unless $u->writer;
    my $quserid   = $u->{userid} + 0;
    my $qnodetype = $u->quote($nodetype);
    my $qnodeid   = $nodeid + 0;

    # now perform action
    my $in       = join( ',', map { $_ + 0 } @$ids );
    my $newstate = $unfreeze ? 'A' : 'F';
    my $res      = $u->talk2_do( $nodetype, $nodeid, undef,
              "UPDATE talk2 SET state = '$newstate' "
            . "WHERE journalid = $quserid AND nodetype = $qnodetype "
            . "AND nodeid = $qnodeid AND jtalkid IN ($in)" );

    # invalidate talk2row memcache props
    LJ::Talk::invalidate_talk2row_memcache( $u->id, @$ids );

    return undef unless $res;
    return 1;
}

sub screen_comment {
    my $u = shift;
    return undef unless LJ::isu($u);
    my $itemid   = shift(@_) + 0;
    my @jtalkids = @_;

    my $in = join( ',', map { $_ + 0 } @jtalkids );
    return unless $in;

    my $userid = $u->{'userid'} + 0;

    my $updated = $u->talk2_do( "L", $itemid, undef,
              "UPDATE talk2 SET state='S' "
            . "WHERE journalid=$userid AND jtalkid IN ($in) "
            . "AND nodetype='L' AND nodeid=$itemid "
            . "AND state NOT IN ('S','D')" );
    return undef unless $updated;

    # invalidate talk2row memcache props
    LJ::Talk::invalidate_talk2row_memcache( $u->id, @jtalkids );
    LJ::MemCache::delete( [ $userid, "activeentries:$userid" ] );

    if ( $updated > 0 ) {
        LJ::replycount_do( $u, $itemid, "decr", $updated );
        LJ::set_logprop( $u, $itemid, { 'hasscreened' => 1 } );
    }

    LJ::MemCache::delete( [ $userid, "screenedcount:$userid:$itemid" ] );

    LJ::Talk::update_commentalter( $u, $itemid );
    return;
}

sub unscreen_comment {
    my $u = shift;
    return undef unless LJ::isu($u);
    my $itemid   = shift(@_) + 0;
    my @jtalkids = @_;

    my $in = join( ',', map { $_ + 0 } @jtalkids );
    return unless $in;

    my $userid = $u->{'userid'} + 0;
    my $prop   = LJ::get_prop( "log", "hasscreened" );

    my $updated = $u->talk2_do( "L", $itemid, undef,
              "UPDATE talk2 SET state='A' "
            . "WHERE journalid=$userid AND jtalkid IN ($in) "
            . "AND nodetype='L' AND nodeid=$itemid "
            . "AND state='S'" );
    return undef unless $updated;

    LJ::Talk::invalidate_talk2row_memcache( $u->id, @jtalkids );
    LJ::MemCache::delete( [ $userid, "activeentries:$userid" ] );

    # update in-memory singletons so they reflect the new state
    foreach my $jtalkid (@jtalkids) {
        my $c = LJ::Comment->new( $u, jtalkid => $jtalkid );
        $c->{state} = 'A' if $c->{_loaded_row};
    }

    if ( $updated > 0 ) {
        LJ::replycount_do( $u, $itemid, "incr", $updated );
        my $dbcm        = LJ::get_cluster_master($u);
        my $hasscreened = $dbcm->selectrow_array( "SELECT COUNT(*) FROM talk2 "
                . "WHERE journalid=$userid AND nodeid=$itemid AND nodetype='L' AND state='S'" );
        LJ::set_logprop( $u, $itemid, { 'hasscreened' => 0 } ) unless $hasscreened;
    }

    LJ::MemCache::delete( [ $userid, "screenedcount:$userid:$itemid" ] );

    LJ::Talk::update_commentalter( $u, $itemid );

    # fire events so that users who missed the original notification
    # (because the comment was screened) get notified now
    my @jobs;
    foreach my $jtalkid (@jtalkids) {
        push @jobs,
            LJ::Event::JournalNewComment->new_for_unscreen(
            LJ::Comment->new( $u, jtalkid => $jtalkid ) );
    }
    DW::TaskQueue->dispatch(@jobs) if @jobs;

    return;
}

# retrieves data from the talk2 table (but preferably memcache)
# returns a hashref (key -> { 'talkid', 'posterid', 'datepost', 'datepost_unix',
#                             'parenttalkid', 'state' } , or undef on failure
sub get_talk_data {
    my ( $u, $nodetype, $nodeid ) = @_;
    return undef unless LJ::isu($u);
    return undef unless $nodetype =~ /^\w$/;
    return undef unless $nodeid =~ /^\d+$/;

    my $ret = {};

    # check for data in memcache
    my $DATAVER     = "3";        # single character
    my $PACK_FORMAT = "NNNNC";    ## $talkid, $parenttalkid, $poster, $time, $state
    my $RECORD_SIZE = 17;

    my $memkey  = [ $u->{'userid'}, "talk2:$u->{'userid'}:$nodetype:$nodeid" ];
    my $lockkey = $memkey->[1];
    my $packed  = LJ::MemCache::get($memkey);

    # we check the replycount in memcache, the value we count, and then fix it up
    # if it seems necessary.
    my $rp_memkey = $nodetype eq "L" ? [ $u->{'userid'}, "rp:$u->{'userid'}:$nodeid" ] : undef;
    my $rp_count = $rp_memkey ? LJ::MemCache::get($rp_memkey) : 0;
    $rp_count ||=
        0;   # avoid warnings, FIXME how can LJ::MemCache::get return undef or sg that is not undef?

    # hook for tests to count memcache gets
    if ($LJ::_T_GET_TALK_DATA_MEMCACHE) {
        $LJ::_T_GET_TALK_DATA_MEMCACHE->();
    }

    my $rp_ourcount = 0;
    my $fixup_rp    = sub {
        return unless $nodetype eq "L";
        return if $rp_count == $rp_ourcount;
        return unless @LJ::MEMCACHE_SERVERS;
        return unless $u->writer;

        my $gc = LJ::gearman_client();
        if ( $gc && LJ::conf_test( $LJ::FIXUP_USING_GEARMAN, $u ) ) {
            $gc->dispatch_background(
                "fixup_logitem_replycount",
                Storable::nfreeze( [ $u->id, $nodeid ] ),
                {
                    uniq => "-",
                }
            );
        }
        else {
            LJ::Talk::fixup_logitem_replycount( $u, $nodeid );
        }
    };

    # Save the talkdata on the entry for later
    my $set_entry_cache = sub {
        return 1 unless $nodetype eq 'L';

        my $entry = LJ::Entry->new( $u, jitemid => $nodeid );
        $entry->set_talkdata($ret);
    };

    my $memcache_good = sub {
        return
               $packed
            && substr( $packed, 0, 1 ) eq $DATAVER
            && length($packed) % $RECORD_SIZE == 1;
    };

    my $memcache_decode = sub {
        my $n = ( length($packed) - 1 ) / $RECORD_SIZE;
        for ( my $i = 0 ; $i < $n ; $i++ ) {
            my ( $talkid, $par, $poster, $time, $state ) =
                unpack( $PACK_FORMAT, substr( $packed, $i * $RECORD_SIZE + 1, $RECORD_SIZE ) );
            $state = chr($state);
            $ret->{$talkid} = {
                talkid        => $talkid,
                state         => $state,
                posterid      => $poster,
                datepost_unix => $time,
                datepost      => LJ::mysql_time($time),    # timezone surely fucked.  deprecated.
                parenttalkid  => $par,
            };

            # comments are counted if they're 'A'pproved or 'F'rozen
            $rp_ourcount++ if $state eq "A" || $state eq "F";
        }
        $fixup_rp->();

        # set cache in LJ::Entry object for this set of comments
        $set_entry_cache->();

        return $ret;
    };

    return $memcache_decode->() if $memcache_good->();

    my $dbcr = LJ::get_cluster_def_reader($u);
    return undef unless $dbcr;

    my $lock = $dbcr->selectrow_array( "SELECT GET_LOCK(?,10)", undef, $lockkey );
    return undef unless $lock;

    # it's quite likely (for a popular post) that the memcache was
    # already populated while we were waiting for the lock
    $packed = LJ::MemCache::get($memkey);
    if ( $memcache_good->() ) {
        $dbcr->selectrow_array( "SELECT RELEASE_LOCK(?)", undef, $lockkey );
        $memcache_decode->();
        return $ret;
    }

    my $memval = $DATAVER;
    my $sth =
        $dbcr->prepare( "SELECT t.jtalkid AS 'talkid', t.posterid, "
            . "t.datepost, UNIX_TIMESTAMP(t.datepost) as 'datepost_unix', "
            . "t.parenttalkid, t.state "
            . "FROM talk2 t "
            . "WHERE t.journalid=? AND t.nodetype=? AND t.nodeid=?" );
    $sth->execute( $u->{'userid'}, $nodetype, $nodeid );
    die $dbcr->errstr if $dbcr->err;
    while ( my $r = $sth->fetchrow_hashref ) {
        $ret->{ $r->{'talkid'} } = $r;

        {
            # make a new $r-type hash which also contains nodetype and nodeid
            # -- they're not in $r because they were known and specified in the query
            my %row_arg = %$r;
            $row_arg{nodeid}   = $nodeid;
            $row_arg{nodetype} = $nodetype;

            # set talk2row memcache key for this bit of data
            LJ::Talk::add_talk2row_memcache( $u->id, $r->{talkid}, \%row_arg );
        }

        $memval .= pack( $PACK_FORMAT,
            $r->{'talkid'},        $r->{'parenttalkid'}, $r->{'posterid'},
            $r->{'datepost_unix'}, ord( $r->{'state'} ) );

        $rp_ourcount++ if $r->{'state'} eq "A";
    }
    LJ::MemCache::set( $memkey, $memval );
    $dbcr->selectrow_array( "SELECT RELEASE_LOCK(?)", undef, $lockkey );

    $fixup_rp->();

    # set cache in LJ::Entry object for this set of comments
    $set_entry_cache->();

    return $ret;
}

sub fixup_logitem_replycount {
    my ( $u, $jitemid ) = @_;

    # attempt to get a database lock to make sure that nobody else is in this section
    # at the same time we are
    my $nodetype = "L";    # this is only for logitem comment counts

    my $rp_memkey = [ $u->{'userid'}, "rp:$u->{'userid'}:$jitemid" ];
    my $rp_count  = LJ::MemCache::get($rp_memkey) || 0;
    my $fix_key   = "rp_fixed:$u->{userid}:$nodetype:$jitemid:$rp_count";

    my $db_key   = "rp:fix:$u->{userid}:$nodetype:$jitemid";
    my $got_lock = $u->selectrow_array( "SELECT GET_LOCK(?, 1)", undef, $db_key );
    return unless $got_lock;

    # setup an unlock handler
    my $unlock = sub {
        $u->do( "SELECT RELEASE_LOCK(?)", undef, $db_key );
        return undef;
    };

    # check memcache to see if someone has previously fixed this entry in this journal
    # with this reply count
    my $was_fixed = LJ::MemCache::get($fix_key);
    return $unlock->() if $was_fixed;

    # if we're doing innodb, begin a transaction, else lock tables
    my $sharedmode = "";
    if ( $u->is_innodb ) {
        $sharedmode = "LOCK IN SHARE MODE";
        $u->begin_work;
    }
    else {
        $u->do("LOCK TABLES log2 WRITE, talk2 READ");
    }

    # get count and then update.  this should be totally safe because we've either
    # locked the tables or we're in a transaction.
    my $ct = $u->selectrow_array(
        "SELECT COUNT(*) FROM talk2 FORCE INDEX (nodetype) WHERE "
            . "journalid=? AND nodetype='L' AND nodeid=? "
            . "AND state IN ('A','F') $sharedmode",
        undef, $u->{'userid'}, $jitemid
    );
    $u->do( "UPDATE log2 SET replycount=? WHERE journalid=? AND jitemid=?",
        undef, int($ct), $u->{'userid'}, $jitemid );

    # now, commit or unlock as appropriate
    if ( $u->is_innodb ) {
        $u->commit;
    }
    else {
        $u->do("UNLOCK TABLES");
    }

    # mark it as fixed in memcache, so we don't do this again
    LJ::MemCache::add( $fix_key, 1, 60 );
    $unlock->();
    LJ::MemCache::set( $rp_memkey, int($ct) );
}

# LJ::Talk::load_comments($u, $remote, $nodetype, $nodeid, $opts)
#
# nodetype: "L" (for log) ... nothing else has been used
# noteid: the jitemid for log.
# opts keys:
#   thread -- jtalkid to thread from ($init->{'thread'} or $GET{'thread'} >> 8)
#   page -- $GET{'page'}
#   view -- $GET{'view'} (picks page containing view's ditemid)
#   flat -- boolean:  if set, threading isn't done, and it's just a flat chrono view
#   up -- [optional] hashref of user object who posted the thing being replied to
#         only used to make things visible which would otherwise be screened?
#   filter -- [optional] value of comments getarg (screened|frozen|visible)
#         used to hide comments not matching the specified type
#   out_error -- set by us if there's an error code:
#        nodb:  database unavailable
#        noposts:  no posts to load
#   out_pages:  number of pages
#   out_page:  page number being viewed
#   out_itemfirst:  first comment number on page (1-based, not db numbers)
#   out_itemlast:  last comment number on page (1-based, not db numbers)
#   out_pagesize:  size of each page
#   out_items:  number of total top level items
#   out_has_collpased:  set by us; 0 if no collapsed messages, 1 if there are
#
#   userpicref -- hashref to load userpics into, or undef to
#                 not load them.
#   userref -- hashref to load users into, keyed by userid
#   top-only -- boolean; if set, only load the top-level comments
#
# returns:
#   array of hashrefs containing keys:
#      - talkid (jtalkid)
#      - posterid (or zero for anon)
#      - userpost (string, or blank if anon)
#      - upost    ($u object, or undef if anon)
#      - datepost (mysql format)
#      - parenttalkid (or zero for top-level)
#      - parenttalkid_actual (set when the $flat mode is set, in which case parenttalkid is always faked to be 0)
#      - state ("A"=approved, "S"=screened, "D"=deleted stub)
#      - userpic number
#      - picid   (if userpicref AND userref were given)
#      - subject
#      - body
#      - props => { propname => value, ... }
#      - children => [ hashrefs like these ]
#      - _loaded => 1 (if fully loaded, subject & body)
#        unknown items will never be _loaded
#      - _show => {0|1}, if item is to be ideally shown (0 if deleted, screened, or filtered)
#      - showable_children - count of showable children for this comment
#      - hidden_child => {0|1}, if this comment is hidden by default
#      - hide_children => {0|1}, if this comment has its children hidden
#      - echi (explicit comment hierarchy indicator)
sub load_comments {
    my ( $u, $remote, $nodetype, $nodeid, $opts ) = @_;

    my $n       = $u->{'clusterid'};
    my $viewall = $opts->{viewall};

    my $posts = get_talk_data( $u, $nodetype, $nodeid );    # hashref, talkid -> talk2 row, or undef
    unless ($posts) {
        $opts->{'out_error'} = "nodb";
        return;
    }
    my %users_to_load;                                      # userid -> 1
    my @posts_to_load;                                      # talkid scalars
    my %children;                                           # talkid -> [ childenids+ ]

    my $uposterid = $opts->{'up'} ? $opts->{'up'}->{'userid'} : 0;

    my $post_count = 0;
    {
        my %showable_children;                              # $id -> $count

        foreach my $post ( sort { $b->{'talkid'} <=> $a->{'talkid'} } values %$posts ) {

            # kill the threading in flat mode
            if ( $opts->{'flat'} ) {
                $post->{'parenttalkid_actual'} = $post->{'parenttalkid'};
                $post->{'parenttalkid'}        = 0;
            }

            # see if we should ideally show it or not.  even if it's
            # zero, we'll still show it if it has any children (but we won't show content)
            my $state        = $post->{state} || '';
            my $should_show  = $state eq 'D' ? 0 : 1;    # no deleted comments
            my $parenttalkid = $post->{parenttalkid};
            unless ($viewall) {

                # first check to see if a filter has been requested
                my $poster    = LJ::load_userid( $post->{posterid} );
                my %filtermap = (
                    screened => sub { return $state eq 'S' },
                    frozen   => sub { return $state eq 'F' },
                    visible  => sub {
                        return 0 if $state eq 'S';
                        return 0 if $poster && $poster->is_suspended;

                        # no need to check if deleted, because $should_show does that for us

                        return 1;
                    },
                );
                if ( $should_show && $opts->{filter} && exists $filtermap{ $opts->{filter} } ) {
                    $should_show = $filtermap{ $opts->{filter} }->();
                }

                # then check for comment owner/journal owner
                $should_show = 0
                    if $should_show &&    # short circuit, and check the following conditions
                                          # only if we wanted to show in the first place
                        # can view if not screened, or if screened and some conditions apply
                    $state eq "S"
                    && !(
                    $remote
                    && (
                        $remote->userid == $uposterid        ||   # made in remote's journal
                        $remote->userid == $post->{posterid} ||   # made by remote
                        $remote->can_manage($u)              ||   # made in a journal remote manages
                        (
                            # remote authored the parent, and this comment is by an admin
                            exists $posts->{$parenttalkid}
                            && $posts->{$parenttalkid}->{posterid}
                            && $posts->{$parenttalkid}->{posterid} == $remote->userid
                            && $poster
                            && $poster->can_manage($u)
                        )
                    )
                    );
            }
            $post->{'_show'} = $should_show;
            $post_count += $should_show;

            # make any post top-level if it says it has a parent but it isn't
            # loaded yet which means either a) row in database is gone, or b)
            # somebody maliciously/accidentally made their parent be a future
            # post, which could result in an infinite loop, which we don't want.
            $post->{'parenttalkid'} = 0
                if $post->{'parenttalkid'} && !$posts->{ $post->{'parenttalkid'} };

            $post->{'children'} =
                [ map { $posts->{$_} } @{ $children{ $post->{'talkid'} } || [] } ];

            # increment the parent post's number of showable children,
            # which is our showability plus all those of our children
            # which were already computed, since we're working new to old
            # and children are always newer.
            # then, if we or our children are showable, add us to the child list
            my $sum = $should_show + ( $showable_children{ $post->{talkid} } || 0 );
            if ($sum) {
                $showable_children{ $post->{'parenttalkid'} } += $sum;
                unshift @{ $children{ $post->{'parenttalkid'} } }, $post->{'talkid'};

                # record the # of showable children for each comment (though
                # not for the post itself (0))
                if ( $post->{parenttalkid} ) {
                    $posts->{ $post->{parenttalkid} }->{'showable_children'} =
                        $showable_children{ $post->{'parenttalkid'} };
                }
            }

        }

        # explicit comment hierarchy indicator generation
        my $echi_display = '';
        $echi_display = $remote->prop("opt_echi_display") || '' if $remote;
        if ( !$opts->{flat} && $echi_display eq "Y" ) {

            my @alpha = ( "a" .. "z" );

            # all echi values are initially stored as numeric values; this
            # translates from the number to a..z, a[a..z]..z[a..z], etc.
            my $to_alpha = sub {
                my $num = shift;

                # this is 0-based, while the count is 1-based.
                $num--;
                my $retval = "";

                # prepend a third letter only if we have more than 702
                # comments (26^2 = 676, plus the initial 26 which don't
                # have a second letter = 702)
                if ( $num >= 702 ) {
                    $retval .= $alpha[ ( $num - 702 ) / 676 ];
                }
                if ( $num >= 26 ) {
                    $retval .= $alpha[ ( ( $num - 26 ) / 26 ) % 26 ];
                }
                $retval .= $alpha[ $num % 26 ];
                return $retval;
            };

            my $top_counter = 1;

            foreach my $post ( sort { $a->{'talkid'} <=> $b->{'talkid'} } values %$posts ) {
                next unless $post->{_show} || $post->{showable_children};

                # set the echi for this comment
                my $parentid = $post->{'parenttalkid'} || $post->{'parenttalkid_actual'} || 0;
                if ( $parentid && $posts->{$parentid} ) {
                    my $parent = $posts->{$parentid};
                    $post->{'echi_count'} = 0;
                    if ( !$parent->{'echi_count'} ) {
                        $parent->{'echi_count'} = 1;
                    }
                    else {
                        $parent->{'echi_count'} = $parent->{'echi_count'} + 1;
                    }
                    if ( !$parent->{'echi_type'} ) {
                        $parent->{'echi_type'} = 'N';
                    }
                    if ( $parent->{'echi_type'} eq 'N' ) {
                        $post->{'echi_type'} = 'A';
                        $post->{echi} = $parent->{echi} . $to_alpha->( $parent->{'echi_count'} );
                    }
                    else {
                        $post->{'echi_type'} = 'N';
                        $post->{echi}        = $parent->{echi} . $parent->{'echi_count'};
                    }
                }
                else {
                    $post->{echi}         = $top_counter++;
                    $post->{'echi_count'} = 0;
                    $post->{'echi_type'}  = 'N';
                }

                # Count number of non-whitespace characters in echi
                my $char_count = $post->{echi} =~ tr/a-zA-Z0-9//;

                # Add a whitespace every ten non-whitespace characters
                $post->{echi} = $post->{echi} . ' ' if ( $char_count % 10 == 0 );
            }
        }
    }

    # with a wrong thread number, silently default to the whole page
    my $thread = $opts->{'thread'} + 0;
    $thread = 0 unless $posts->{$thread};

    unless ( $thread || $children{$thread} ) {
        $opts->{'out_error'} = "noposts";
        return;
    }

    my $page_size       = $LJ::TALK_PAGE_SIZE    || 25;
    my $max_subjects    = $LJ::TALK_MAX_SUBJECTS || 200;
    my $threading_point = $LJ::TALK_THREAD_POINT || 50;

    # we let the page size initially get bigger than normal for awhile,
    # but if it passes threading_point, then everything's in page_size
    # chunks:
    $page_size = $threading_point if $post_count < $threading_point;

    my $top_replies = $thread ? 1 : scalar( @{ $children{$thread} } );
    my $pages       = int( $top_replies / $page_size );
    if ( $top_replies % $page_size ) { $pages++; }

    my @top_replies    = $thread ? ($thread) : @{ $children{$thread} };
    my $page_from_view = 0;
    if ( $opts->{'view'} && !$opts->{'page'} ) {

        # find top-level comment that this comment is under
        my $viewid = $opts->{'view'} >> 8;
        while ( $posts->{$viewid} && $posts->{$viewid}->{'parenttalkid'} ) {
            $viewid = $posts->{$viewid}->{'parenttalkid'};
        }
        for ( my $ti = 0 ; $ti < @top_replies ; ++$ti ) {
            if ( $posts->{ $top_replies[$ti] }->{'talkid'} == $viewid ) {
                $page_from_view = int( $ti / $page_size ) + 1;
                last;
            }
        }
    }
    my $page = int( $opts->{page} || 0 ) || $page_from_view || 1;
    $page = $page < 1 ? 1 : $page > $pages ? $pages : $page;

    my $itemfirst = $page_size * ( $page - 1 ) + 1;
    my $itemlast  = $page == $pages ? $top_replies : ( $page_size * $page );

    @top_replies = @top_replies[ $itemfirst - 1 .. $itemlast - 1 ];

    push @posts_to_load, @top_replies;

    # mark child posts of the top-level to load, deeper
    # and deeper until we've hit the page size.  if too many loaded,
    # just mark that we'll load the subjects;
    my @check_for_children = @posts_to_load;

    ## expand first reply to top-level comments
    ## %expand_children - list of comments, children of which are to expand
    my %expand_children;
    unless ( $opts->{'top-only'} ) {
        ## expand first reply to top-level comments
        ## %expand_children - list of comments, children of which are to expand
        %expand_children = map { $_ => 1 } @top_replies;
    }

    my ( @subjects_to_load, @subjects_ignored );

    # track if there are any collapsed messages being displayed
    my $has_collapsed = 0;

    while (@check_for_children) {
        my $cfc = shift @check_for_children;

        next unless defined $children{$cfc};
        foreach my $child ( @{ $children{$cfc} } ) {
            if ( !$opts->{'top-only'}
                && ( @posts_to_load < $page_size || $expand_children{$cfc} || $opts->{expand_all} )
                )
            {
                push @posts_to_load, $child;
                ## expand only the first child, then clear the flag
                delete $expand_children{$cfc};
            }
            else {
                $has_collapsed = 1;
                if ( $opts->{'top-only'} ) {
                    $posts->{$child}->{'hidden_child'} = 1;
                }
                if ( @subjects_to_load < $max_subjects ) {
                    push @subjects_to_load, $child;
                }
                else {
                    push @subjects_ignored, $child;
                }
            }
            push @check_for_children, $child;
        }
    }

    $opts->{'out_pages'}         = $pages;
    $opts->{'out_page'}          = $page;
    $opts->{'out_itemfirst'}     = $itemfirst;
    $opts->{'out_itemlast'}      = $itemlast;
    $opts->{'out_pagesize'}      = $page_size;
    $opts->{'out_items'}         = $top_replies;
    $opts->{'out_has_collapsed'} = $has_collapsed;

    # load text of posts
    my ( $posts_loaded, $subjects_loaded );
    $posts_loaded = LJ::get_talktext2( $u, @posts_to_load );
    $subjects_loaded = LJ::get_talktext2( $u, { 'onlysubjects' => 1 }, @subjects_to_load )
        if @subjects_to_load;

    # preload props
    my @ids_to_preload = @posts_to_load;
    push @ids_to_preload, @subjects_to_load;
    my @to_preload = ();
    foreach my $jtalkid (@ids_to_preload) {
        push @to_preload, LJ::Comment->new( $u, jtalkid => $jtalkid );
    }
    LJ::Comment->preload_props( $u, @to_preload );

    foreach my $talkid (@posts_to_load) {
        if ( $opts->{'top-only'} ) {
            $posts->{$talkid}->{'hide_children'} = 1;
        }
        next unless $posts->{$talkid}->{'_show'};
        $posts->{$talkid}->{'_loaded'}                    = 1;
        $posts->{$talkid}->{'subject'}                    = $posts_loaded->{$talkid}->[0];
        $posts->{$talkid}->{'body'}                       = $posts_loaded->{$talkid}->[1];
        $users_to_load{ $posts->{$talkid}->{'posterid'} } = 1;
        if ( $opts->{'top-only'} ) {
            $posts->{$talkid}->{'hide_children'} = 1;
        }
    }
    foreach my $talkid (@subjects_to_load) {
        next unless $posts->{$talkid}->{'_show'};
        $posts->{$talkid}->{'subject'} = $subjects_loaded->{$talkid}->[0];
        $users_to_load{ $posts->{$talkid}->{'posterid'} } ||= 0.5;    # only care about username
    }
    foreach my $talkid (@subjects_ignored) {
        next unless $posts->{$talkid}->{'_show'};
        $posts->{$talkid}->{'subject'} = "...";
        $users_to_load{ $posts->{$talkid}->{'posterid'} } ||= 0.5;    # only care about username
    }

    # load meta-data
    {
        my %props;
        LJ::load_talk_props2( $u->{'userid'}, \@posts_to_load, \%props );
        foreach ( keys %props ) {
            next unless $posts->{$_}->{'_show'};
            $posts->{$_}->{'props'} = $props{$_};
        }
    }

    foreach (@posts_to_load) {
        if ( $posts->{$_}->{'props'}->{'unknown8bit'} ) {
            LJ::item_toutf8( $u, \$posts->{$_}->{'subject'}, \$posts->{$_}->{'body'}, {} );
        }
    }

    # load users who posted
    delete $users_to_load{0};
    my %up = ();
    if (%users_to_load) {
        LJ::load_userids_multiple( [ map { $_, \$up{$_} } keys %users_to_load ] );

        # fill in the 'userpost' member on each post being shown
        while ( my ( $id, $post ) = each %$posts ) {
            my $up = $up{ $post->{'posterid'} };
            next unless $up;
            $post->{'upost'}    = $up;
            $post->{'userpost'} = $up->{'user'};
        }
    }

    # optionally give them back user refs
    if ( ref( $opts->{userref} ) eq "HASH" ) {
        my %userpics = ();

        # copy into their ref the users we've already loaded above.
        while ( my ( $k, $v ) = each %up ) {
            $opts->{userref}->{$k} = $v;
        }

        # optionally load userpics
        if ( ref( $opts->{userpicref} ) eq "HASH" ) {
            my @load_pic;
            foreach my $talkid (@posts_to_load) {
                my $post = $posts->{$talkid};
                my $pu   = $opts->{userref}->{ $post->{posterid} };
                my ( $id, $kw );
                if ( $pu && $pu->userpic_have_mapid ) {
                    my $mapid;
                    if ( $post->{props} && $post->{props}->{picture_mapid} ) {
                        $mapid = $post->{props}->{picture_mapid};
                    }
                    $kw = $pu ? $pu->get_keyword_from_mapid($mapid) : undef;
                    $id = $pu ? $pu->get_picid_from_mapid($mapid)   : undef;
                }
                else {
                    if ( $post->{props} && $post->{props}->{picture_keyword} ) {
                        $kw = $post->{props}->{picture_keyword};
                    }
                    $id = $pu ? $pu->get_picid_from_keyword($kw) : undef;
                }
                $post->{picid} = $id;
                $post->{pickw} = $kw;
                push @load_pic, [ $pu, $id ]
                    if defined $id;
            }
            load_userpics( $opts->{userpicref}, \@load_pic );
        }
    }

    # make singletons for the returned comments
    my $make_comment_singleton = sub {
        my ( $self, $jtalkid, $row ) = @_;
        return 1 unless $nodetype eq 'L';

        # at this point we have data for this comment loaded in memory
        # -- instantiate an LJ::Comment object as a singleton and absorb
        #    that data into the object
        my $comment = LJ::Comment->new( $u, jtalkid => $jtalkid );

        # add important info to row
        $row->{nodetype} = $nodetype;
        $row->{nodeid}   = $nodeid;
        $comment->absorb_row(%$row);

        $comment->{childids}         = $row->{children};
        $comment->{_loaded_childids} = 1;

        if ( $row->{children} && scalar @{ $row->{children} } ) {
            foreach my $child ( @{ $row->{children} } ) {
                $self->( $self, $child, $posts->{$child} );
            }
        }
        return 1;
    };

    foreach my $talkid (@top_replies) {
        $make_comment_singleton->( $make_comment_singleton, $talkid, $posts->{$talkid} );
    }

    return map { $posts->{$_} } @top_replies;
}

# <LJFUNC>
# name: LJ::Talk::load_userpics
# des: Loads a bunch of userpics at once.
# args: dbarg?, upics, idlist
# des-upics: hashref to load pictures into, keys being the picids.
# des-idlist: [$u, $picid] or [[$u, $picid], [$u, $picid], +] objects
#             also supports deprecated old method, of an array ref of picids.
# </LJFUNC>
sub load_userpics {
    my ( $upics, $idlist ) = @_;

    return undef unless ref $idlist eq 'ARRAY' && $idlist->[0];

    # $idlist needs to be an arrayref of arrayrefs,
    # HOWEVER, there's a special case where it can be
    # an arrayref of 2 items:  $u (which is really an arrayref)
    # as well due to 'fields' and picid which is an integer.
    #
    # [$u, $picid] needs to map to [[$u, $picid]] while allowing
    # [[$u1, $picid1], [$u2, $picid2], [etc...]] to work.
    if ( scalar @$idlist == 2 && !ref $idlist->[1] ) {
        $idlist = [$idlist];
    }

    my @load_list;
    foreach my $row ( @{$idlist} ) {
        my ( $u, $id ) = @$row;
        next unless ref $u && defined $id;

        if ( $LJ::CACHE_USERPIC{$id} ) {
            $upics->{$id} = $LJ::CACHE_USERPIC{$id};
        }
        elsif ( $id + 0 ) {
            push @load_list, [ $u, $id + 0 ];
        }
    }
    return unless @load_list;

    if (@LJ::MEMCACHE_SERVERS) {
        my @mem_keys = map { [ $_->[1], "userpic.$_->[1]" ] } @load_list;
        my $mem      = LJ::MemCache::get_multi(@mem_keys) || {};
        while ( my ( $k, $v ) = each %$mem ) {
            next unless $v && $k =~ /(\d+)/;
            my $id = $1;
            $upics->{$id} = LJ::MemCache::array_to_hash( "userpic", $v );
        }
        @load_list = grep { !$upics->{ $_->[1] } } @load_list;
        return unless @load_list;
    }

    my %db_load;
    foreach my $row (@load_list) {

        # ignore users on clusterid 0
        next unless $row->[0]->{clusterid};

        push @{ $db_load{ $row->[0]->{clusterid} } }, $row;
    }

    foreach my $cid ( keys %db_load ) {
        my $dbcr = LJ::get_cluster_def_reader($cid);
        unless ($dbcr) {
            print STDERR "Error: LJ::Talk::load_userpics unable to get handle; cid = $cid\n";
            next;
        }

        my ( @bindings, @data );
        foreach my $row ( @{ $db_load{$cid} } ) {
            push @bindings, "(userid=? AND picid=?)";
            push @data, ( $row->[0]->{userid}, $row->[1] );
        }
        next unless @data && @bindings;

        my $sth =
            $dbcr->prepare( "SELECT userid, picid, width, height, fmt, state, "
                . "       UNIX_TIMESTAMP(picdate) AS 'picdate', location, flags "
                . "FROM userpic2 WHERE "
                . join( ' OR ', @bindings ) );
        $sth->execute(@data);

        while ( my $ur = $sth->fetchrow_hashref ) {
            my $id = delete $ur->{'picid'};
            $upics->{$id} = $ur;

            # force into numeric context so they'll be smaller in memcache:
            foreach my $k (qw(userid width height flags picdate)) {
                $ur->{$k} += 0;
            }
            $ur->{location} = uc( substr( $ur->{location} || '', 0, 1 ) );

            $LJ::CACHE_USERPIC{$id} = $ur;
            LJ::MemCache::set( [ $id, "userpic.$id" ],
                LJ::MemCache::hash_to_array( "userpic", $ur ) );
        }
    }
}

sub talkform {

    # Takes a hashref with the following keys / values:
    # journalu:    required journal user object
    # parpost:     parent comment hashref. Only keys we use are state and subject.
    # replyto:     jtalkid of the parent comment (or 0 if replying to entry)
    # ditemid:     target entry's ditemid
    # styleopts:   the style options (`?style=light`) at reply time, as a hashref
    # thread:      thread being viewed at reply time (`?thread=12345`), as a dtalkid
    # form:        optional full form hashref. Empty if reply page was opened via
    #              direct link instead of partial form submission.
    # do_captcha:  optional toggle for creating a captcha challenge
    # errors:      optional arrayref of errors to display, so user can fix

   # Refresher course on IDs:
   # Entries and comments have real and "display" ids. This is for reader comfort, not for security.
   # "anum" is a random but permanent number attached to entries.
   # entry->jitemid * 256 + entry->anum = entry->ditemid
   # (usually "itemid" means a jitemid, but reply forms pass a ditemid in their "itemid" field.)
   # comment->jtalkid * 256 + comment->entry->anum = comment->dtalkid
   # parenttalkid/replyto is always a jtalkid. Sometimes you'll see "dtid" to mean dtalkid.
    my $opts = shift;
    return "Invalid talkform values." unless ref $opts eq 'HASH';

    my ( $journalu, $parpost, $form ) =
        map { $opts->{$_} } qw(journalu parpost form);

    my $remote = LJ::get_remote();

    my $editid = $form->{editid} || 0;
    my $comment;

    if ($editid) {
        $comment = LJ::Comment->new( $journalu, dtalkid => $editid );
        return "Cannot load comment information." unless $comment;
    }

    # A few early exit conditions, before we bother with all this other work:
    # make sure journal isn't locked
    return
        "Sorry, this journal is locked and comments cannot be posted to it or edited at this time."
        if $journalu->is_locked;

    # check max comments (if posting a new comment; edits are ok.)
    unless ($editid) {
        my $jitemid = $opts->{'ditemid'} >> 8;
        return "Sorry, this entry already has the maximum number of comments allowed."
            if LJ::Talk::Post::over_maxcomments( $journalu, $jitemid );
    }

    my $subjecticons    = LJ::Talk::get_subjecticons();
    my @subjecticon_ids = ('none');
    foreach my $sublist ( $subjecticons{lists}->{sm}, $subjecticons{lists}->{md} ) {
        push( @subjecticon_ids, map { $_->{id} } @$sublist );
    }

    my $entry = LJ::Entry->new( $journalu, ditemid => $opts->{ditemid} );

    my $basesubject = $form->{subject} || "";
    if ( !$editid && $opts->{replyto} && !$basesubject && $parpost->{'subject'} ) {
        $basesubject = $parpost->{'subject'};
        $basesubject =~ s/^Re:\s*//i;
        $basesubject = "Re: $basesubject";
    }

    # hashref with "selected" and "items" keys
    my $editors = DW::Formats::select_items(
        current   => $form->{prop_editor},
        preferred => $remote ? $remote->prop('comment_editor') : '',
    );

    my $screening = LJ::Talk::screening_level( $journalu, $opts->{ditemid} >> 8 ) // '';

    # pre-calculate some abilities and add them to $remote, so we don't have to do it
    # in the template

    my $remote_opts;
    if ($remote) {
        $remote_opts->{can_manage_community} =
               $journalu->is_community
            && $remote
            && $remote->can_manage($journalu);
        $remote_opts->{can_unscreen_parent} =
            (      $parpost->{state}
                && $parpost->{state} eq "S"
                && LJ::Talk::can_unscreen( $remote, $journalu, $entry->poster ) );

        $remote_opts->{allowed} = !$journalu->does_not_allow_comments_from($remote);
        $remote_opts->{banned}  = $journalu->has_banned($remote);
        $remote_opts->{screened} =
            (      $journalu->has_autoscreen($remote)
                || $screening eq 'A'
                || ( $screening eq 'R' && !$remote->is_validated )
                || ( $screening eq 'F' && !$journalu->trusts($remote) ) );

    }

    # Variables for talkform.tt (most of them, at least)
    my $template_args = {
        hidden_form_elements => '',
        form_url             => LJ::create_url( '/talkpost_do', host => $LJ::DOMAIN_WEB ),
        errors               => $opts->{errors},
        create_link          => '',
        subjecticon_ids      => \@subjecticon_ids,
        editors              => $editors,

        foundation_beta => !LJ::BetaFeatures->user_in_beta( $remote => "nos2foundation" ),

        public_entry     => $entry->security eq 'public',
        default_usertype => 'user',

        comment => {
            editid      => $editid,
            editreason  => $form->{editreason} // ( $comment ? $comment->edit_reason : '' ),
            oidurl      => $form->{oidurl},
            oiddo_login => $form->{oiddo_login},
            user        => $form->{userpost},
            password    => $form->{password},
            do_login    => $form->{do_login},
            body        => $form->{body},
            subject     => $basesubject,
            subjecticon => $form->{subjecticon}
                || 'none',    # a subjecticon ID
            preformatted    => $form->{prop_opt_preformatted},
            admin_post      => $form->{prop_admin_post},
            current_icon_kw => $form->{prop_picture_keyword},
            current_icon => LJ::Userpic->new_from_keyword( $remote, $form->{prop_picture_keyword} ),
        },

        captcha => $opts->{do_captcha}
        ? {
            type => $journalu->captcha_type,
            html => DW::Captcha->new( undef, want => $journalu->captcha_type )->print,
            }
        : 0,

        remote      => $remote ? $remote : 0,
        remote_opts => $remote_opts,
        journal     => {
            user => $journalu->{user},

            is_iplogging => $journalu->opt_logcommentips eq 'A' ? 'all'
            : $journalu->opt_logcommentips eq 'S' ? 'anon'
            : 0,
            is_linkstripped => !$remote
                || ( $remote && $remote->is_identity && !$journalu->trusts_or_has_member($remote) ),
            is_community => $journalu->is_community,

            screens_anon       => $screening,
            screens_non_access => $screening eq 'F' || $screening eq 'A',
            screens_all        => $screening eq 'A',

            allows_anon       => $journalu->{opt_whocanreply} eq "all",
            allows_non_access => $journalu->{opt_whocanreply} eq "all"
                || $journalu->{opt_whocanreply} eq "reg",
        },

        help_icon               => sub { LJ::help_icon_html(@_) },
        print_subjecticon_by_id => sub { return LJ::Talk::print_subjecticon_by_id(@_) },
    };

    # Now, we munge some of the more complex template inputs that can make
    # a mess inline.

    # default_usertype is the initial selected item in the "from" options.
    # It defaults to 'user' above, but something else might be better.
    # Reminder: allowed usertypes are:
    # anonymous
    # openid
    # openid_cookie (logged-in)
    # cookieuser (logged-in)
    # user (w/ name provided in "userpost")
    if ( $form->{usertype} ) {

        # Partial form was submitted, and they already told us who they want to
        # be! Pick up where they left off.
        $template_args->{default_usertype} = $form->{usertype};

        # But there are two exceptions to that. First, quick-reply doesn't know
        # about openid_cookie and always says cookieuser, so if they're
        # logged in as OpenID, straighten that out.
        if (   $form->{usertype} eq 'cookieuser'
            && $remote
            && $remote->is_identity )
        {
            $template_args->{default_usertype} = 'openid_cookie';
        }

        # Second, if the logged-in user isn't allowed to comment, it's
        # impossible to select "current user." Revert to the default.
        if (
            (
                   $template_args->{default_usertype} eq 'cookieuser'
                || $template_args->{default_usertype} eq 'openid_cookie'
            )
            && $remote
            && !$template_args->{remote_opts}->{allowed}
            )
        {
            $template_args->{default_usertype} = 'user';
        }
    }
    elsif ( $remote && $template_args->{remote_opts}->{allowed} ) {

        # Whole point of logging in is to be the default user, so, yeah.
        if ( $remote->is_identity ) {
            $template_args->{default_usertype} = 'openid_cookie';
        }
        else {
            $template_args->{default_usertype} = 'cookieuser';
        }
    }
    elsif ( $journalu->{'opt_whocanreply'} eq "all" ) {
        $template_args->{default_usertype} = 'anonymous';
    }

    my $styleopts = $opts->{styleopts} || LJ::viewing_style_opts(%$form);

    # hidden values
    $template_args->{'hidden_form_elements'} .= LJ::html_hidden(
        replyto        => $opts->{replyto},
        parenttalkid   => ( $opts->{replyto} + 0 ),
        itemid         => $opts->{ditemid},
        journal        => $journalu->{user},
        editid         => $editid,
        viewing_thread => $opts->{form}->{viewing_thread} || $opts->{thread} || 0,
        chrp1          => generate_chrp1( $journalu->{userid}, $opts->{ditemid} ),
        %$styleopts,
    );

    # special link to create an account
    if ( !$remote || $remote->openid_identity ) {
        $template_args->{'create_link'} =
            LJ::Hooks::run_hook( "override_create_link_on_talkpost_form", $journalu ) || '';
    }

    return DW::Template->template_string( 'journal/talkform.tt', $template_args );
}

# Generate anti-spam challenge/response value
sub generate_chrp1 {
    my ( $journal_userid, $ditemid ) = @_;

    my ( $time, $secret ) = LJ::get_secret();
    my $rchars = LJ::rand_chars(20);
    my $chal   = $ditemid . "-$journal_userid-$time-$rchars";
    my $res    = Digest::MD5::md5_hex( $secret . $chal );
    return "$chal-$res";
}

# validate the challenge/response value (anti-spammer)
# This is distinct from the outdated challenge/response login method, and
# doesn't require any client-side md5 horseplay; it's just an expiring
# server-provided token that's impractical to forge.
# Returns (1, undef) if valid, (0, errorstring) if not.
sub validate_chrp1 {
    my ($chrp) = @_;
    my $fail = sub { return ( 0, $_[0] ); };
    my $ok   = sub { return ( 1, undef ); };

    if ( !$chrp ) {
        $fail->("missing");
    }
    my ( $c_ditemid, $c_uid, $c_time, $c_chars, $c_res ) =
        split( /\-/, $chrp );
    my $chal   = "$c_ditemid-$c_uid-$c_time-$c_chars";
    my $secret = LJ::get_secret($c_time);
    my $res    = Digest::MD5::md5_hex( $secret . $chal );
    if ( $res ne $c_res ) {
        $fail->("invalid");
    }
    elsif ( $c_time < time() - 2 * 60 * 60 ) {
        $fail->("too_old") if $LJ::REQUIRE_TALKHASH_NOTOLD;
    }

    $ok->();
}

sub icon_dropdown {
    my ( $remote, $selected ) = @_;
    $selected ||= "";

    my %res;
    if ($remote) {
        LJ::do_request(
            {
                mode      => "login",
                ver       => $LJ::PROTOCOL_VER,
                user      => $remote->{'user'},
                getpickws => 1,
            },
            \%res,
            { "noauth" => 1, "userid" => $remote->{'userid'} }
        );
    }

    my $ret = "";
    if ( $res{pickw_count} ) {
        $ret .= LJ::Lang::ml('/journal/talkform.tt.label.picturetouse2') . " ";

        my @pics;
        foreach my $i ( 1 ... $res{pickw_count} ) {
            push @pics, $res{"pickw_$i"};
        }
        @pics = sort { lc($a) cmp lc($b) } @pics;
        $ret .= LJ::html_select(
            {
                name     => 'prop_picture_keyword',
                selected => $selected,
                id       => 'prop_picture_keyword'
            },
            ( "", LJ::Lang::ml('entryform.opt.defpic'), map { ( $_, $_ ) } @pics )
        );

        # userpic browse button
        if ( $remote && $remote->can_use_userpic_select ) {
            my $metatext   = $remote->iconbrowser_metatext   ? "true" : "false";
            my $smallicons = $remote->iconbrowser_smallicons ? "true" : "false";
            $ret .=
qq{<input type="button" id="lj_userpicselect" value="Browse" data-iconbrowser-metatext="$metatext" data-iconbrowser-smallicons="$smallicons"/>};
        }

        # random icon button - hidden for non-JS
        $ret .= "<input type='button' class='ljhidden' id='randomicon' value='"
            . LJ::Lang::ml('/journal/talkform.tt.userpic.random2') . "'/>";

        $ret .= LJ::help_icon_html( "userpics", " " );
    }

    return $ret;
}

# load the javascript libraries for the icon browser
# args: none
# returns: nothing, just calls need_res
sub init_iconbrowser_js {

    # There are three separate implementations of the icon browser.

    # The New-New Icon Browser: Depends on Foundation CSS/JS (either site skin
    # or minimal version). Used on: Create entry page (if in "updatepage" beta),
    # quickreply and talkform on journal pages and on talkpost_do.
    LJ::need_res(
        { group => 'foundation' },

        'js/foundation/foundation/foundation.js',
        'js/foundation/foundation/foundation.reveal.js',
        'js/components/jquery.icon-browser.js',
        'stc/css/components/icon-browser.css',
    );

    # The Old-New Icon Browser: Depends on jQuery; only used if NOT in the
    # "s2foundation" beta. Used on: Quick-reply and talkform on journal pages,
    # talkform on talkpost_do for errors/previews .
    LJ::need_res(
        { group => 'jquery' },

        # base libraries
        'js/jquery/jquery.ui.core.js',
        'js/jquery/jquery.ui.widget.js',
        'stc/jquery/jquery.ui.core.css',

        # for the formatting of the icon selector popup
        'js/jquery/jquery.ui.dialog.js',
        'stc/jquery/jquery.ui.dialog.css',

        # logic for the icon selector
        'js/jquery.iconselector.js',
        'stc/jquery.iconselector.css',
    );

    # The Old-Old Icon Browser: Weird ancient technology. Not the exciting kind.
    # Used on: New entry page (if NOT in "updatepage" beta), inbox's "compose"
    # page (always).
    LJ::need_res(

        # Explicitly specify "default" group to keep the CSS out of the
        # 'all' group, because we almost never want it.
        { group => 'default' },

        # base libraries
        'js/6alib/core.js',
        'js/6alib/dom.js',
        'js/6alib/json.js',

        # for the formatting of the icon selector popup
        'js/6alib/template.js',
        'js/6alib/ippu.js',
        'js/lj_ippu.js',

        # logic for the icon selector
        'js/userpicselect.js',

        # fetching the userpic information
        'js/6alib/httpreq.js',
        'js/6alib/hourglass.js',

        # autocomplete
        'js/6alib/inputcomplete.js',
        'stc/ups.css',

        # selecting an icon by clicking on a row
        'js/6alib/datasource.js',
        'js/6alib/selectable_table.js',
    );
}

# convenience/deduplication function for QR, cut tag, & icon browser JS loading
# args: hash of opts to determine which JS files to load (iconbrowser, siteskin, lastn, noqr)
# returns: nothing, just calls need_res
sub init_s2journal_js {
    my %opts = @_;

    # TODO: change resource group to "foundation" in all these after the
    # s2foundation beta ends.

    # load for everywhere that displays entry and/or comment text.
    # quick-reply.css is for both reply forms. (TODO: rename that.)
    LJ::need_res(
        { group => "all" }, qw(
            js/jquery/jquery.ui.widget.js
            js/jquery.replyforms.js
            stc/css/components/quick-reply.css
            stc/css/components/icon-select.css
            js/jquery.poll.js
            js/journals/jquery.tag-nav.js
            js/jquery.mediaplaceholder.js
            js/jquery.imageshrink.js
            js/components/jquery.icon-select.js
            stc/css/components/imageshrink.css
            )
    );

    # load for quick reply (every view except ReplyPage).
    # threadexpander is only for EntryPage, but whatever.
    LJ::need_res(
        { group => "all" }, qw(
            js/jquery/jquery.ui.core.js
            stc/jquery/jquery.ui.core.css
            js/jquery/jquery.ui.widget.js
            js/jquery.quickreply.js
            js/jquery.threadexpander.js
            )
    ) unless $opts{noqr};

    # load only for ReplyPage
    LJ::need_res(
        { group => "all" }, qw(
            js/jquery.talkform.js
            stc/css/components/talkform.css
            )
    ) if $opts{noqr};

    # load for userpicselect
    if ( $opts{iconbrowser} ) {
        my $remote = LJ::get_remote();
        init_iconbrowser_js() if $remote && $remote->can_use_userpic_select;
    }

    # if we're using the site skin, don't override the jquery-ui theme,
    # as that's already included
    LJ::need_res(
        { group => "all" }, qw(
            stc/jquery/jquery.ui.theme.smoothness.css
            )
    ) unless $opts{siteskin};

    # load for ajax cuttag and ajax quickreply - only needed on lastn-type pages
    LJ::need_res(
        { group => "all" }, qw(
            js/jquery/jquery.ui.widget.js
            js/jquery.cuttag-ajax.js
            js/jquery.default-editor.js
            )
    ) if $opts{lastn};
}

# convenience function for keyboard shortcuts
# args: $remote and $p for adding javascript to header
# returns: nothing, just calls need_res
sub init_s2journal_shortcut_js {
    my ( $remote, $p ) = @_;

    # skip everything if there's no remote user, or if neither opt_shortcuts
    # nor opt_shortcuts_touch is set.
    return
        unless $remote
        && ( $remote->prop("opt_shortcuts") || $remote->prop("opt_shortcuts_touch") );

    my $connect_string = "";

    LJ::need_res( { group => "all" }, "js/shortcuts.js" );
    LJ::need_res( { group => "all" }, "js/jquery.shortcuts.nextentry.js" );

    $p->{'head_content'} .= "  <script type='text/javascript'>\n  var dw_shortcuts = {\n";
    if ( $remote->prop("opt_shortcuts") ) {
        LJ::need_res( { group => "all" }, "js/mousetrap.js" );
        $p->{'head_content'} .= "    keyboard: {\n";

        my $nextKey = $remote->prop("opt_shortcuts_next");
        my $prevKey = $remote->prop("opt_shortcuts_prev");
        $p->{'head_content'} .=
            "      nextEntry: '$nextKey',\n      prevEntry: '$prevKey'\n    }\n";
        $connect_string = ",";
    }
    if ( $remote->prop("opt_shortcuts_touch") ) {
        LJ::need_res( { group => "all" }, "js/jquery.touchSwipe.js" );
        my $nextTouch = $remote->prop("opt_shortcuts_touch_next");
        my $prevTouch = $remote->prop("opt_shortcuts_touch_prev");
        $p->{'head_content'} .=
"$connect_string\n    touch: {\n      nextEntry: '$nextTouch',\n      prevEntry: '$prevTouch'\n    }\n";
    }
    $p->{'head_content'} .= "  };\n  </script>\n";
}

# <LJFUNC>
# name: LJ::record_anon_comment_ip
# class: web
# des: Records the IP address of an anonymous comment.
# args: journalu, jtalkid, ip
# des-journalu: User object of journal comment was posted in.
# des-jtalkid: ID of this comment.
# des-ip: IP address of the poster.
# returns: 1 for success, 0 for failure
# </LJFUNC>
sub record_anon_comment_ip {
    my ( $journalu, $jtalkid, $ip ) = @_;
    $journalu = LJ::want_user($journalu);
    $jtalkid += 0;
    return 0 unless LJ::isu($journalu) && $jtalkid && $ip;

    $journalu->do(
"INSERT INTO tempanonips (reporttime, journalid, jtalkid, ip) VALUES (UNIX_TIMESTAMP(),?,?,?)",
        undef, $journalu->{userid}, $jtalkid, $ip
    );
    return 0 if $journalu->err;
    return 1;
}

# <LJFUNC>
# name: LJ::mark_comment_as_spam
# class: web
# des: Copies a comment into the global [dbtable[spamreports]] table.
# args: journalu, jtalkid
# des-journalu: User object of journal comment was posted in.
# des-jtalkid: ID of this comment.
# returns: 1 for success, 0 for failure
# </LJFUNC>
sub mark_comment_as_spam {
    my ( $journalu, $jtalkid ) = @_;
    $journalu = LJ::want_user($journalu);
    $jtalkid += 0;
    return 0 unless $journalu && $jtalkid;

    my $dbcr = LJ::get_cluster_def_reader($journalu);
    my $dbh  = LJ::get_db_writer();

    # step 1: get info we need
    my $row  = LJ::Talk::get_talk2_row( $dbcr, $journalu->{userid}, $jtalkid );
    my $temp = LJ::get_talktext2( $journalu, $jtalkid );
    my ( $subject, $body, $posterid ) =
        ( $temp->{$jtalkid}[0], $temp->{$jtalkid}[1], $row->{posterid} );
    return 0 unless ( $body && $body ne '' );

    # can't mark your own comments as spam.
    return 0 if $posterid && $posterid == $journalu->id;

    # can't mark comments as spam if sysbanned
    return 0 if LJ::sysban_check( 'spamreport', $journalu->user );

    # step 2a: if it's a suspended user, don't add, but pretend that we were successful
    if ($posterid) {
        my $posteru = LJ::want_user($posterid);
        return 1 if $posteru->is_suspended;
    }

 # step 2b: if it was an anonymous comment, attempt to get comment IP to make some use of the report
    my $ip;
    unless ($posterid) {
        $ip = $dbcr->selectrow_array( 'SELECT ip FROM tempanonips WHERE journalid=? AND jtalkid=?',
            undef, $journalu->{userid}, $jtalkid );
        return 0 if $dbcr->err;

        # we want to fail out if we have no IP address and this is anonymous, because otherwise
        # we have a completely useless spam report.  pretend we were successful, too.
        return 1 unless $ip;

        # we also want to log this attempt so that we can do some throttling
        my $rates = LJ::MemCache::get("spamreports:anon:$ip") || $RATE_DATAVER;
        $rates .= pack( "N", time );
        LJ::MemCache::set( "spamreports:anon:$ip", $rates );
    }

    my %props;
    LJ::load_talk_props2( $dbcr, $journalu->{userid}, [$jtalkid], \%props );

    # step 3: insert into spamreports
    $dbh->do(
'INSERT INTO spamreports (reporttime, posttime, ip, journalid, posterid, subject, body, client) '
            . 'VALUES (UNIX_TIMESTAMP(), UNIX_TIMESTAMP(?), ?, ?, ?, ?, ?, ?)',
        undef,
        $row->{datepost},
        $ip,
        $journalu->{userid},
        $posterid,
        $subject,
        $body,
        $props{$jtalkid}->{useragent}
    );
    return 0 if $dbh->err;
    return 1;
}

# <LJFUNC>
# name: LJ::Talk::get_talk2_row
# class: web
# des: Gets a row of data from [dbtable[talk2]].
# args: dbcr, journalid, jtalkid
# des-dbcr: Database handle to read from.
# des-journalid: Journal id that comment is posted in.
# des-jtalkid: Journal talkid of comment.
# returns: Hashref of row data, or undef on error.
# </LJFUNC>
sub get_talk2_row {
    my ( $dbcr, $journalid, $jtalkid ) = @_;
    return $dbcr->selectrow_hashref(
        'SELECT journalid, jtalkid, nodetype, nodeid, parenttalkid, '
            . '       posterid, datepost, state '
            . 'FROM talk2 WHERE journalid = ? AND jtalkid = ?',
        undef,
        $journalid + 0,
        $jtalkid + 0
    );
}

# <LJFUNC>
# name: LJ::Talk::get_talk2_row_multi
# class: web
# des: Gets multiple rows of data from [dbtable[talk2]].
# args: items
# des-items: Array of arrayrefs; each arrayref: [ journalu, jtalkid ].
# returns: Array of hashrefs of row data, or undef on error.
# </LJFUNC>
sub get_talk2_row_multi {
    my (@items) = @_;    # [ journalu, jtalkid ], ...
    croak("invalid items for get_talk2_row_multi")
        if grep { !LJ::isu( $_->[0] ) || @$_ != 2 } @items;

    # what do we need to load per-journalid
    my %need    = ();    # journalid => { jtalkid => 1, ... }
    my %have    = ();    # journalid => { jtalkid => $row_ref, ... }
    my %cluster = ();    # cid => { jid => journalu, jid => journalu }

    # first, what is in memcache?
    my @keys = ();
    foreach my $it (@items) {
        my ( $journalu, $jtalkid ) = @$it;

        # can't load comments in purged users' journals
        next if $journalu->is_expunged;

        my $cid = $journalu->clusterid;
        my $jid = $journalu->id;

        # we need this for now
        $need{$jid}->{$jtalkid} = 1;

        # which cluster is this user on?
        $cluster{$cid}->{$jid} = $journalu;

        push @keys, LJ::Talk::make_talk2row_memkey( $jid, $jtalkid );
    }

    # return an array of rows preserving order in which they were requested
    my $ret = sub {
        my @ret = ();
        foreach my $it (@items) {
            my ( $journalu, $jtalkid ) = @$it;
            push @ret, $have{ $journalu->id }->{$jtalkid};
        }

        return @ret;
    };

    my $mem = LJ::MemCache::get_multi(@keys);
    if ($mem) {
        while ( my ( $key, $array ) = each %$mem ) {
            my ( undef, $jid, $jtalkid ) = split( ":", $key );
            my $row = LJ::MemCache::array_to_hash( "talk2row", $array );
            next unless $row;

            # add in implicit keys:
            $row->{journalid} = $jid;
            $row->{jtalkid}   = $jtalkid;

            # update our needs
            $have{$jid}->{$jtalkid} = $row;
            delete $need{$jid}->{$jtalkid};
            delete $need{$jid} unless %{ $need{$jid} };
        }

        # was everything in memcache?
        return $ret->() unless %need;
    }

    # uh oh, we have things to retrieve from the db!
CLUSTER:
    foreach my $cid ( keys %cluster ) {

        # build up a valid where clause for this cluster's select
        my @vals  = ();
        my @where = ();
        foreach my $journalu ( values %{ $cluster{$cid} } ) {
            my $jid      = $journalu->id;
            my @jtalkids = keys %{ $need{$jid} };
            next unless @jtalkids;

            my $bind = join( ",", map { "?" } @jtalkids );
            push @where, "(journalid=? AND jtalkid IN ($bind))";
            push @vals, $jid => @jtalkids;
        }

        # is there anything to actually query for this cluster?
        next CLUSTER unless @vals;

        my $dbcr = LJ::get_cluster_reader($cid)
            or die "unable to get cluster reader: $cid";

        my $where = join( " OR ", @where );
        my $sth =
            $dbcr->prepare( "SELECT journalid, jtalkid, nodetype, nodeid, parenttalkid, "
                . "       posterid, datepost, state "
                . "FROM talk2 WHERE $where" );
        $sth->execute(@vals);

        while ( my $row = $sth->fetchrow_hashref ) {
            my $jid     = $row->{journalid};
            my $jtalkid = $row->{jtalkid};

            # update our needs
            $have{$jid}->{$jtalkid} = $row;
            delete $need{$jid}->{$jtalkid};
            delete $need{$jid} unless %{ $need{$jid} };

            # update memcache
            LJ::Talk::add_talk2row_memcache( $jid, $jtalkid, $row );
        }
    }

    return $ret->();
}

sub make_talk2row_memkey {
    my ( $jid, $jtalkid ) = @_;
    return [ $jid, join( ":", "talk2row", $jid, $jtalkid ) ];
}

sub add_talk2row_memcache {
    my ( $jid, $jtalkid, $row ) = @_;

    my $memkey  = LJ::Talk::make_talk2row_memkey( $jid, $jtalkid );
    my $exptime = 60 * 30;
    my $array   = LJ::MemCache::hash_to_array( "talk2row", $row );

    return LJ::MemCache::add( $memkey, $array, $exptime );
}

sub invalidate_talk2row_memcache {
    my ( $jid, @jtalkids ) = @_;

    foreach my $jtalkid (@jtalkids) {
        my $memkey = [ $jid, "talk2row:$jid:$jtalkid" ];
        LJ::MemCache::delete($memkey);
    }

    return 1;
}

# get a comment count for a journal entry.
sub get_replycount {
    my ( $ju, $jitemid ) = @_;
    $jitemid += 0;
    return undef unless $ju && $jitemid;

    my $memkey = [ $ju->{'userid'}, "rp:$ju->{'userid'}:$jitemid" ];
    my $count  = LJ::MemCache::get($memkey);
    return $count if $count;

    my $dbcr = LJ::get_cluster_def_reader($ju);
    return unless $dbcr;

    $count =
        $dbcr->selectrow_array( "SELECT replycount FROM log2 WHERE " . "journalid=? AND jitemid=?",
        undef, $ju->{'userid'}, $jitemid );
    LJ::MemCache::add( $memkey, $count );
    return $count;
}

# get the total amount of screened comments on the given journal entry
sub get_screenedcount {
    my ( $ju, $jitemid ) = @_;
    $jitemid += 0;
    return undef unless $ju && $jitemid;

    my $memkey = [ $ju->{userid}, "screenedcount:$ju->{userid}:$jitemid", 60 * 30 ];
    my $count  = LJ::MemCache::get($memkey);
    return $count if $count;

    my $dbcr = LJ::get_cluster_def_reader($ju);
    return unless $dbcr;

    $count = $dbcr->selectrow_array(
        "SELECT COUNT(jtalkid) FROM talk2 WHERE " . "journalid=? AND nodeid=? AND state='S'",
        undef, $ju->{userid}, $jitemid );
    LJ::MemCache::add( $memkey, $count );
    return $count;
}

sub comment_htmlid {
    my $id = shift or return '';
    return "cmt$id";
}

sub comment_anchor {
    my $id = shift or return '';
    return "#cmt$id";
}

sub treat_as_anon {
    my ( $pu, $u ) = @_;
    return 1 unless LJ::isu($pu);        # anonymous is not OK
    return 0 unless $pu->is_identity;    # OK unless OpenID
                                         # if OpenID, not OK unless they're granted access
    return LJ::isu($u) ? !$u->trusts_or_has_member($pu) : 1;
}

sub format_eventtime {
    my ( $etime, $u ) = @_;
    $etime ||= '';
    $etime =~ s!(\d{4}-\d{2}-\d{2})!LJ::date_to_view_links( $u, $1 )!e;
    return "<br /><span class='time'>@ $etime</span>";
}

package LJ::Talk::Post;

use Text::Wrap;
use LJ::Entry;

sub indent {
    my $a        = shift;
    my $leadchar = shift || " ";
    $Text::Wrap::columns = 76;
    return Text::Wrap::wrap( "$leadchar ", "$leadchar ", $a );
}

sub blockquote {
    my $a = shift;
    return
"<blockquote style='border-left: #000040 2px solid; margin-left: 0px; margin-right: 0px; padding-left: 15px; padding-right: 0px'>$a</blockquote>";
}

# An implementation detail of LJ::Talk::Post::post_comment; takes care of the
# gnarly database stuff. This is not expected to be called from anywhere else.
# (skull and crossbones emoji)
# Returns (1, talkid) on success, (0, error) on failure.
sub enter_comment {
    my ($comment) = @_;

    my $item     = $comment->{entry};
    my $journalu = $item->journal;
    my $parent   = $comment->{parent};
    my $partid   = $parent->{talkid};
    my $itemid   = $item->jitemid;

    # accepts multi-part errors if you're into that.
    my $err = sub {
        return ( 0, join( ": ", @_ ) );
    };

    return $err->("Invalid user object passed.")
        unless LJ::isu($journalu);

    my $jtalkid = LJ::alloc_user_counter( $journalu, "T" );
    return $err->( "Database Error", "Could not generate a talkid necessary to post this comment." )
        unless $jtalkid;

    # insert the comment
    my $posterid = $comment->{u} ? $comment->{u}{userid} : 0;

    my $errstr;
    $journalu->talk2_do(
        "L",
        $itemid,
        \$errstr,
        "INSERT INTO talk2 "
            . "(journalid, jtalkid, nodetype, nodeid, parenttalkid, posterid, datepost, state) "
            . "VALUES (?,?,'L',?,?,?,NOW(),?)",
        $journalu->{userid},
        $jtalkid,
        $itemid,
        $partid,
        $posterid,
        $comment->{state}
    );
    if ($errstr) {
        return $err->(
            "Database Error",
            "There was an error posting your comment to the database.  "
                . "Please report this.  The error is: <b>$errstr</b>"
        );
    }

    LJ::MemCache::incr( [ $journalu->{'userid'}, "talk2ct:$journalu->{'userid'}" ] );

    # record IP if anonymous
    LJ::Talk::record_anon_comment_ip( $journalu, $jtalkid, LJ::get_remote_ip() )
        unless $posterid;

    # add to poster's talkleft table, or the xfer place
    if ($posterid) {
        my $table;
        my $db = LJ::get_cluster_master( $comment->{u} );

        if ($db) {

            # remote's cluster is writable
            $table = "talkleft";
        }
        else {
            # log to global cluster, another job will move it later.
            $db    = LJ::get_db_writer();
            $table = "talkleft_xfp";
        }
        my $pub = $item->security eq "public" ? 1 : 0;
        if ($db) {
            $db->do(
                "INSERT INTO $table (userid, posttime, journalid, nodetype, "
                    . "nodeid, jtalkid, publicitem) VALUES (?, UNIX_TIMESTAMP(), "
                    . "?, 'L', ?, ?, ?)",
                undef, $posterid, $journalu->{userid}, $itemid, $jtalkid, $pub
            );

            LJ::MemCache::incr( [ $posterid, "talkleftct:$posterid" ] );
        }
        else {
            # both primary and backup talkleft hosts down.  can't do much now.
        }
    }

    $journalu->do(
        "INSERT INTO talktext2 (journalid, jtalkid, subject, body) " . "VALUES (?, ?, ?, ?)",
        undef,
        $journalu->{userid},
        $jtalkid,
        $comment->{subject},
        LJ::text_compress( $comment->{body} )
    );
    die $journalu->errstr if $journalu->err;

    my $memkey = "$journalu->{'clusterid'}:$journalu->{'userid'}:$jtalkid";
    LJ::MemCache::set( [ $journalu->{'userid'}, "talksubject:$memkey" ], $comment->{subject} );
    LJ::MemCache::set( [ $journalu->{'userid'}, "talkbody:$memkey" ],    $comment->{body} );

    LJ::MemCache::delete( [ $journalu->{userid}, "activeentries:" . $journalu->{userid} ] );
    LJ::MemCache::delete( [ $journalu->{userid}, "screenedcount:$journalu->{userid}:$itemid" ] )
        if $comment->{state} eq 'S';

    # dudata
    my $bytes = length( $comment->{subject} ) + length( $comment->{body} );

    # we used to do a LJ::dudata_set(..) on 'T' here, but decided
    # we could defer that.  to find size of a journal, summing
    # bytes in dudata is too slow (too many seeks)

    my %talkprop;    # propname -> value
                     # meta-data
    $talkprop{'unknown8bit'} = 1 if $comment->{unknown8bit};
    $talkprop{'subjecticon'} = $comment->{subjecticon};

    my $pu = $comment->{u};
    if ( $pu && $pu->userpic_have_mapid ) {
        $talkprop{picture_mapid} = $pu->get_mapid_from_keyword( $comment->{picture_keyword} );
    }
    else {
        $talkprop{picture_keyword} = $comment->{picture_keyword};
    }

    $talkprop{admin_post}         = $comment->{admin_post} ? 1 : 0;
    $talkprop{'opt_preformatted'} = $comment->{preformat}  ? 1 : 0;
    $talkprop{'editor'}           = $comment->{editor};

    my $site_user_comment = $comment->{u} && $comment->{u}->is_person;
    if ( $journalu->opt_logcommentips eq "A"
        || ( $journalu->opt_logcommentips eq "S" && !$site_user_comment ) )
    {
        if ( LJ::is_web_context() ) {
            my $ip        = BML::get_remote_ip();
            my $forwarded = BML::get_client_header('X-Forwarded-For');
            $ip = "$forwarded, via $ip" if $forwarded && $forwarded ne $ip;
            $talkprop{'poster_ip'} = $ip;
        }
    }

    # remove blank/0 values (defaults)
    foreach ( keys %talkprop ) { delete $talkprop{$_} unless $talkprop{$_}; }

    # update the talkprops
    LJ::load_props("talk");
    if (%talkprop) {
        my $values;
        my $hash = {};
        foreach ( keys %talkprop ) {
            my $p = LJ::get_prop( "talk", $_ );
            next unless $p;
            $hash->{$_} = $talkprop{$_};
            my $tpropid = $p->{'tpropid'};
            my $qv      = $journalu->quote( $talkprop{$_} );
            $values .= "($journalu->{'userid'}, $jtalkid, $tpropid, $qv),";
        }
        if ($values) {
            chop $values;
            $journalu->do(
                "INSERT INTO talkprop2 (journalid, jtalkid, tpropid, value) " . "VALUES $values" );
            die $journalu->errstr if $journalu->err;
        }
        LJ::MemCache::set( [ $journalu->{'userid'}, "talkprop:$journalu->{'userid'}:$jtalkid" ],
            $hash );
    }

    # update the "replycount" summary field of the log table
    if ( $comment->{state} eq 'A' ) {
        LJ::replycount_do( $journalu, $itemid, "incr" );
    }

    # update the "hasscreened" property of the log item if needed
    if ( $comment->{state} eq 'S' ) {
        LJ::set_logprop( $journalu, $itemid, { 'hasscreened' => 1 } );
    }

    # update the comment alter property
    LJ::Talk::update_commentalter( $journalu, $itemid );

    # fire events
    my @jobs;

    push @jobs,
        LJ::Event::JournalNewComment->new( LJ::Comment->new( $journalu, jtalkid => $jtalkid ) );

    if (@LJ::SPHINX_SEARCHD) {
        push @jobs,
            DW::Task::SphinxCopier->new(
            { userid => $journalu->id, jtalkid => $jtalkid, source => "commtnew" } );
    }

    DW::TaskQueue->dispatch(@jobs) if @jobs;

    return ( 1, $jtalkid );
}

# this is used by the journal import code, but is kept here so as to be kept
# local to the rest of the comment code
sub enter_imported_comment {
    my ( $journalu, $parent, $item, $comment, $date, $errref ) = @_;

    my $partid   = $parent->{talkid};
    my $itemid   = $item->{itemid};
    my $posterid = $comment->{u} ? $comment->{u}->{userid} : 0;

    my $err = sub {
        $$errref = join( ": ", @_ );
        return 0;
    };

    return $err->("Invalid user object passed.")
        unless LJ::isu($journalu);

    # prealloc counter before insert
    my $jtalkid = LJ::alloc_user_counter( $journalu, "T" );
    return $err->( "Database Error", "Could not generate a talkid necessary to post this comment." )
        unless $jtalkid;

    # insert the comment
    my $errstr;
    $journalu->talk2_do(
        "L", $itemid, \$errstr,
        q{
            INSERT INTO talk2 (journalid, jtalkid, nodetype, nodeid, parenttalkid, posterid, datepost, state)
            VALUES (?,?,'L',?,?,?,?,?)
        },
        $journalu->{userid}, $jtalkid, $itemid, $partid, $posterid, $date, $comment->{state}
    );

    return $err->(
        "Database Error",
        "There was an error posting your comment to the database.  "
            . "Please report this.  The error is: <b>$errstr</b>"
    ) if $errstr;

    LJ::MemCache::delete( [ $journalu->{userid}, "talk2ct:$journalu->{userid}" ] );

    # add to poster's talkleft table, or the xfer place
    if ($posterid) {
        my $table;
        my $db = LJ::get_cluster_master( $comment->{u} );

        if ($db) {

            # remote's cluster is writable
            $table = "talkleft";
        }
        else {
            # log to global cluster, another job will move it later.
            $db    = LJ::get_db_writer();
            $table = "talkleft_xfp";
        }

        my $pub = $item->{'security'} eq "public" ? 1 : 0;
        if ($db) {
            $db->do(
                qq{
                    INSERT INTO $table (userid, posttime, journalid, nodetype, nodeid, jtalkid, publicitem)
                    VALUES (?, UNIX_TIMESTAMP(?), ?, 'L', ?, ?, ?)
                }, undef, $posterid, $date, $journalu->{userid}, $itemid, $jtalkid, $pub
            );
            LJ::MemCache::delete( [ $posterid, "talkleftct:$posterid" ] );
        }
        else {
            # both primary and backup talkleft hosts down.  can't do much now.
            warn "Unable to insert comment into talkleft, cluster+master down?";
        }
    }

    if ( $comment->{state} ne "D" ) {
        $journalu->do(
            q{
                INSERT INTO talktext2 (journalid, jtalkid, subject, body)
                VALUES (?, ?, ?, ?)
            }, undef, $journalu->{userid}, $jtalkid, $comment->{subject},
            LJ::text_compress( $comment->{body} )
        );
        die $journalu->errstr if $journalu->err;
    }

    my %talkprop;    # propname -> value

    foreach my $key ( keys %{ $comment->{props} || {} } ) {
        $talkprop{$key} = $comment->{props}->{$key};
    }

    $talkprop{'unknown8bit'} = 1 if $comment->{unknown8bit};
    $talkprop{'subjecticon'} = $comment->{subjecticon};

    my $pu = $comment->{u};
    if ( $pu && $pu->userpic_have_mapid ) {
        $talkprop{picture_mapid} =
            $pu->get_mapid_from_keyword( $comment->{picture_keyword}, create => 1 );
    }
    else {
        $talkprop{picture_keyword} = $comment->{picture_keyword};
    }

    $talkprop{'opt_preformatted'} = $comment->{preformat} ? 1 : 0;

    # remove blank/0 values (defaults)
    foreach ( keys %talkprop ) {
        delete $talkprop{$_} unless $talkprop{$_};
    }

    # update the talkprops
    LJ::load_props("talk");
    if (%talkprop) {
        my $values;
        my $hash = {};
        foreach ( keys %talkprop ) {
            my $p = LJ::get_prop( "talk", $_ );
            next unless $p;
            $hash->{$_} = $talkprop{$_};
            my $tpropid = $p->{'tpropid'};
            my $qv      = $journalu->quote( $talkprop{$_} );
            $values .= "($journalu->{'userid'}, $jtalkid, $tpropid, $qv),";
        }
        if ($values) {
            chop $values;
            $journalu->do(
                "INSERT INTO talkprop2 (journalid, jtalkid, tpropid, value) " . "VALUES $values" );
            die $journalu->errstr if $journalu->err;
        }
    }

    # update the "replycount" summary field of the log table
    if ( $comment->{state} eq 'A' || $comment->{state} eq 'F' ) {
        LJ::replycount_do( $journalu, $itemid, "incr" );
    }

    # update the "hasscreened" property of the log item if needed
    if ( $comment->{state} eq 'S' ) {
        LJ::set_logprop( $journalu, $itemid, { 'hasscreened' => 1 } );
    }

    # update the comment alter property
    LJ::Talk::update_commentalter( $journalu, $itemid );

    return $jtalkid;
}

# Checks permissions and consistency for a submitted comment, then returns a
# comment hashref that can be passed to post_comment or edit_comment (or undef
# if the comment wouldn't be allowed).
# Replacement for LJ::Talk::Post::init.
# This ONLY deals with the content and relationships of the comment itself;
# user authentication and frontend concerns belong elsewhere.
#
# Args:
# $content: a reply form hashref, representing the comment as submitted.
#   Mostly won't get mutated here, but the captcha check requires minor
#   finagling. Fields we use:
#   - body
#   - subject
#   - prop_something (various)
#   - editid and editreason, if editing
#   - parenttalkid: integer, comment being replied to (0 if replying to entry)
#   - replyto: duplicate of parenttalkid, for some reason
#   - subjecticon
#   - any captcha-related fields from the talkform (varies by captcha type)
#   Other fields are ignored.
# $commenter: user object, or undef for anonymous
# $entry: LJ::Entry object
# $need_captcha: scalar ref to mutate; if the caller can't ask a human
#     for a captcha response, it probably bails if this comes back truthy.
# $errret: array ref to push errors to. If we return undef, this says why.
sub prepare_and_validate_comment {
    my ( $content, $commenter, $entry, $need_captcha, $errret ) = @_;

    my $tp_d = '/talkpost_do.tt';    # for ml strings

    # Commenter can be undef for anon, but yes, we absolutely need an entry.
    croak("Need LJ::Entry object to reply to") unless $entry->isa('LJ::Entry');

    # For most errors, report and keep going; we'll return undef at the end, and
    # the user can address them all at once. ~But if it's all gone wrong &
    # there's nothing left to learn: go ahead and return~ (guitar)
    my $err = sub {
        my $error = shift;
        push @$errret, $error;
        return undef;
    };
    my $mlerr = sub {
        return $err->( LJ::Lang::ml(@_) );
    };

    my $journalu          = $entry->journal;
    my $commenter_is_user = LJ::isu($commenter);

    # First: accept the things u cannot change. Existential errors a commenter
    # can't do anything about.

    # Can the user even view this post?
    unless ( $entry->visible_to($commenter) ) {
        $mlerr->("$tp_d.error.mustlogin") unless $commenter_is_user;
        $mlerr->("$tp_d.error.noauth");
        return undef;    # Shouldn't tell you anything else about this entry, then.
    }

    # No replying to readonly/locked/expunged journals
    return $mlerr->("$tp_d.error.noreply_readonly_journal")               if $journalu->is_readonly;
    return $mlerr->('talk.error.purged')                                  if $journalu->is_expunged;
    return $err->("Account is locked, unable to post or edit a comment.") if $journalu->is_locked;

    # can ANYONE comment?
    return $mlerr->("$tp_d.error.nocomments") if $entry->comments_disabled;

    # no replying to suspended entries, even by entry poster
    return $mlerr->("$tp_d.error.noreply_suspended") if $entry->is_suspended;

    # check max comments (unless editing existing comment)
    if ( !$content->{editid} && over_maxcomments( $journalu, $entry->jitemid ) ) {
        return $mlerr->("$tp_d.error.maxcomments");
    }

    # If replying to a comment, it's gotta exist. (Hold onto it, we'll want it later.)
    my $parenttalkid = ( $content->{parenttalkid} || $content->{replyto} || 0 ) + 0;
    my $parpost;
    if ($parenttalkid) {
        my $dbcr = LJ::get_cluster_def_reader($journalu);
        return $mlerr->('error.nodb') unless $dbcr;    # tbh we got bigger problems at this point.
        $parpost = LJ::Talk::get_talk2_row( $dbcr, $journalu->{userid}, $parenttalkid );
        unless ($parpost) {
            return $mlerr->("$tp_d.error.noparent");
        }
    }

    # no replying to frozen comments
    my $parent_state = $parpost->{state} // '';
    return $mlerr->("$tp_d.error.noreply_frozen") if $parent_state eq 'F';

    # Next: Permissions checks! Easily solved, just become someone else.

    # (For switching between variant error messages.)
    my $iscomm = $journalu->is_community ? '.comm' : '';

    if ($commenter_is_user) {

        # test accounts can only comment on other test accounts.
        if (   ( grep { $commenter->user eq $_ } @LJ::TESTACCTS )
            && !( grep { $journalu->user eq $_ } @LJ::TESTACCTS )
            && !$LJ::IS_DEV_SERVER )
        {
            $mlerr->("$tp_d.error.testacct");
        }

        # Ban check for journal and entry:
        if ( $journalu->has_banned($commenter) ) {
            $mlerr->("$tp_d.error.banned$iscomm");
        }
        else {
            # comm hasn't banned you, but maybe this poster did
            $mlerr->("$tp_d.error.banned.entryowner") if $entry->poster->has_banned($commenter);
        }

        # Ban check for parent comment:
        my $parentu = LJ::load_userid( $parpost->{posterid} );
        $mlerr->("$tp_d.error.banned.reply")
            if defined $parentu && $parentu->has_banned($commenter);

        # they down with unvalidated OpenIDs?
        if ( $journalu->does_not_allow_comments_from_unconfirmed_openid($commenter) ) {
            $mlerr->(
                "$tp_d.error.noopenidpost",
                {
                    aopts1 => "href='$LJ::SITEROOT/changeemail'",
                    aopts2 => "href='$LJ::SITEROOT/register'"
                }
            );
        }

        # No one's down with unvalidated site users.
        # (FYI, nothing in -free or -nonfree ever registers that hook. -NF)
        if (   $commenter->{'status'} eq "N"
            && !$commenter->is_identity
            && !LJ::Hooks::run_hook( "journal_allows_unvalidated_commenting", $journalu ) )
        {
            $mlerr->( "$tp_d.error.noverify2", { aopts => "href='$LJ::SITEROOT/register'" } );
        }

        # Miscellaneous miscreants:
        $mlerr->("$tp_d.error.purged")                  if $commenter->is_expunged;
        $mlerr->("$tp_d.error.deleted")                 if $commenter->is_deleted;
        $mlerr->("$tp_d.error.suspended")               if $commenter->is_suspended;
        $mlerr->("$tp_d.error.noreply_readonly_remote") if $commenter->is_readonly;

        # members only?
        if ( $journalu->does_not_allow_comments_from_non_access($commenter) ) {
            my $msg = $journalu->is_community ? "notamember" : "notafriend";
            $mlerr->( "$tp_d.error.$msg", { user => $journalu->user } );
        }
    }
    else {
        # I think these would all have been handled by the checks in the other
        # branch, but tradition says anons get different error messages.

        # Doesn't allow anon comments?
        if ( $journalu->does_not_allow_comments_from($commenter) ) {
            $mlerr->("$tp_d.error.noanon$iscomm");
        }

        # members only?
        if ( $journalu->prop('opt_whocanreply') eq 'friends' ) {
            my $msg = $journalu->is_community ? "membersonly" : "friendsonly";
            $mlerr->( "$tp_d.error.$msg", { user => $journalu->user } );
        }
    }

    # Next: consistency checks and munging!
    # (And captcha, after that.)

    # Old init had some UTF8 conversion thing here for POSTs to talkpost_do that
    # included an "encoding" field, but I can't find any way that can possibly
    # happen. Let's just explode instead. -NF
    return $mlerr->("bml.badinput.body1") unless LJ::text_in($content);

    my $body    = $content->{body};
    my $subject = $content->{subject};

    # Cat got your tongue?
    $mlerr->("$tp_d.error.blankmessage") unless $body =~ /\S/;

    # unixify line-endings
    $body =~ s/\r\n/\n/g;

    # Length check:
    my ( $bl, $cl ) = LJ::text_length($body);
    if ( $cl > LJ::CMAX_COMMENT ) {
        $mlerr->(
            "$tp_d.error.manychars",
            {
                current => $cl,
                limit   => LJ::CMAX_COMMENT
            }
        );
    }
    elsif ( $bl > LJ::BMAX_COMMENT ) {
        $mlerr->(
            "$tp_d.error.manybytes",
            {
                current => $bl,
                limit   => LJ::BMAX_COMMENT
            }
        );
    }

    # the subject can be silently shortened, no need to reject the whole comment
    $subject = LJ::text_trim( $subject, 100, 100 );

    # munge subjecticons, not to be confused with Decepticons (or regular icons)
    my $subjecticon = $content->{'subjecticon'} || '';
    $subjecticon = LJ::trim( lc($subjecticon) );
    $subjecticon = '' if $subjecticon eq "none";

    # anti-spam captcha check
    unless ( ref $need_captcha eq 'SCALAR' ) {
        my $nevermind = 0;
        $need_captcha = \$nevermind;
    }

    # If the form already had a captcha, prep it:
    $content->{want} = $content->{captcha_type};    # Captcha->new consumes "want"
    my $captcha = DW::Captcha->new( undef, %{ $content || {} } );

    # are they sending us a response? Check it.
    if ( $captcha->enabled && $captcha->has_response ) {

        # If this isn't their final pass through the form, they'll need a captcha next time too.
        $$need_captcha = 1;

        # TODO: I'd rather only ask for one captcha per interaction.

        my $captcha_error;
        $err->($captcha_error) unless ( $captcha->validate( err_ref => \$captcha_error ) );
    }
    else {
        $$need_captcha =
            LJ::Talk::Post::require_captcha_test( $commenter, $journalu, $body, $entry );

        $err->( LJ::Lang::ml('captcha.title') ) if $$need_captcha;
    }

    # That's the end of Things that Ain't Valid! Roll em up and bail now so the
    # user can fix.
    return undef if @$errret;

    # Ok!! Home free, and almost done.

    # post this comment screened?
    my $state     = 'A';
    my $screening = LJ::Talk::screening_level( $journalu, $entry->jitemid ) || "";
    $screening = 'A' if $journalu->has_autoscreen($commenter);
    if (   $screening eq 'A'
        || ( $screening eq 'R' && !$commenter_is_user )
        || ( $screening eq 'F' && !( $commenter && $journalu->trusts_or_has_member($commenter) ) ) )
    {
        $state = 'S';
    }

    # Finally, ensure that the comment ends up screen if it runs afoul of some
    # testing we do. I.e., if the journal is in the auto-screen list and the IP is
    # in the bad IPs list.
    if ( exists $LJ::AUTOSCREEN_COMMENTS_IN{ $journalu->user } ) {
        if ( $LJ::SHOULD_SCREEN_IP && $LJ::SHOULD_SCREEN_IP->( LJ::get_remote_ip() ) ) {
            $state = 'S';
        }
    }

    # Assemble the final prepared comment!
    my $parent = {
        state    => $parent_state,
        talkid   => $parenttalkid,
        posterid => $parpost->{posterid},
    };
    my $comment = {
        u           => $commenter,
        parent      => $parent,
        entry       => $entry,
        subject     => $subject,
        body        => $body,
        unknown8bit => 0,
        subjecticon => $subjecticon,

        # TODO need a more organized way to carry approved props forward.
        editor          => DW::Formats::validate( $content->{'prop_editor'} ),
        preformat       => $content->{'prop_opt_preformatted'},
        admin_post      => $content->{'prop_admin_post'},
        picture_keyword => $content->{'prop_picture_keyword'},

        state      => $state,
        editid     => $content->{editid},
        editreason => $content->{editreason},
    };

    return $comment;
}

# <LJFUNC>
# name: LJ::Talk::Post::require_captcha_test
# des: returns true if user must answer CAPTCHA (human test) before posting a comment
# args: commenter, journal, body, entry
# des-commenter: User object of author of comment, undef for anonymous commenter
# des-journal: User object of journal where to post comment
# des-body: Text of the comment (may be checked for spam, may be empty)
# des-entry: LJ::Entry object for the entry being commented on
# </LJFUNC>
sub require_captcha_test {
    my ( $commenter, $journal, $body, $entry ) = @_;
    my $ditemid = $entry->ditemid;

    # only require captcha if the site is properly configured for it
    return 0 unless DW::Captcha->site_enabled;

    ## anonymous commenter user =
    ## not logged-in user, or OpenID without validated e-mail
    my $anon_commenter = !LJ::isu($commenter)
        || ( $commenter->identity && !$commenter->is_validated );

    ##
    ## 1. Check rate by remote user and by IP (for anonymous user)
    ##
    my $captcha = DW::Captcha->new;
    if ( $captcha->enabled('anonpost') || $captcha->enabled('authpost') ) {
        return 1 unless LJ::Talk::Post::check_rate( $commenter, $journal );
    }
    if ( $captcha->enabled('anonpost') && $anon_commenter ) {
        return 1 if LJ::sysban_check( 'talk_ip_test', LJ::get_remote_ip() );
    }

    ##
    ## 4. Test preliminary limit on comment.
    ## We must check it before we will allow owner to pass.
    ##
    if ( LJ::Talk::get_replycount( $journal, $ditemid >> 8 ) >=
        $journal->count_maxcomments_before_captcha )
    {
        # Skip the forced captcha for recent entries (posted within the last
        # 30 days). High-comment spam mostly targets old/abandoned entries,
        # and active anon memes shouldn't be penalized for popularity.
        my $age_days = ( time() - $entry->logtime_unix ) / 86400;
        return 1 if $age_days > 30;
    }

    ##
    ## 2. Don't show captcha to the owner of the journal, no more checks
    ##
    if ( !$anon_commenter && $commenter->equals($journal) ) {
        return 0;
    }

    ##
    ## 3. Custom (journal) settings
    ##
    my $show_captcha_to = $journal->prop('opt_show_captcha_to');
    if ( !$show_captcha_to || $show_captcha_to eq 'N' ) {
        ## no one
    }
    elsif ( $show_captcha_to eq 'R' ) {
        ## anonymous
        return 1 if $anon_commenter;
    }
    elsif ( $show_captcha_to eq 'F' ) {
        ## not friends
        return 1 if !$journal->trusts_or_has_member($commenter);
    }
    elsif ( $show_captcha_to eq 'A' ) {
        ## all
        return 1;
    }

    ##
    ## 4. Global (site) settings
    ## See if they have any tags or URLs in the comment's body
    ##
    if ( $captcha->enabled('comment_html_auth')
        || ( $captcha->enabled('comment_html_anon') && $anon_commenter ) )
    {
        return 0 unless $body;    # Before we bother matching against it.

        if ( $body =~ /<[a-z]/i ) {

            # strip white-listed bare tags w/o attributes,
            # then see if they still have HTML.  if so, it's
            # questionable.  (can do evil spammy-like stuff w/
            # attributes and other elements)
            my $body_copy = $body;
            $body_copy =~ s/<(?:q|blockquote|b|strong|i|em|cite|sub|sup|var|del|tt|code|pre|p)>//ig;
            return 1 if $body_copy =~ /<[a-z]/i;
        }

        # multiple URLs is questionable too
        return 1 if $body =~ /\b(?:http|ftp|www)\b.+\b(?:http|ftp|www)\b/s;

        # or if they're not even using HTML
        return 1 if $body =~ /\[url/is;

        # or if it's obviously spam
        return 1 if $body =~ /\s*message\s*/is;
    }
}

# Does what it says on the tin.
# Expects a comment hashref from LJ::Talk::Post::prepare_and_validate_comment.
# Don't yolo one of these hashrefs unless you're in a test.
# returns (1, talkid) on success, (0, error) on fail
# Mutates its received $comment hashref: maybe setting state,
# maybe removing picture_keyword, //= '' on a few things.
sub post_comment {
    my ( $comment, $unscreen_parent ) = @_;

    my $item     = $comment->{entry};
    my $journalu = $item->journal;
    my $parent   = $comment->{parent};
    my $itemid   = $item->jitemid;

    my $parent_state = $parent->{state} || "";

    # unscreen the parent comment if needed
    if ( $parent_state eq 'S' && $unscreen_parent ) {

     # if parent comment is screened and we got this far, the user has the permission to unscreen it
     # in this case the parent comment needs to be unscreened and the comment posted as normal
        LJ::Talk::unscreen_comment( $journalu, $itemid, $parent->{talkid} );
        $parent->{state} = 'A';
    }
    elsif ( $parent_state eq 'S' ) {

      # if the parent comment is screened and the unscreen option was not selected, we also want the
      # reply to be posted as screened
        $comment->{state} = 'S';
    }

    # check for duplicate entry (double submission)
    # Note:  we don't do it inside a locked section like LJ::Protocol's postevent,
    # so it's not perfect, but it works pretty well.
    my $posterid = $comment->{u} ? $comment->{u}{userid} : 0;
    my $jtalkid;

    # check for dup ID in memcache.
    my $memkey;
    if (@LJ::MEMCACHE_SERVERS) {

        # avoid warnings FIXME this should be done elsewhere
        foreach my $field (qw(body subject subjecticon preformat editor admin_post picture_keyword))
        {
            $comment->{$field} = '' if not defined $comment->{$field};
        }
        my $md5_b64 = Digest::MD5::md5_base64(
            join(
                ":",
                (
                    $comment->{body},        $comment->{subject},
                    $comment->{subjecticon}, $comment->{preformat},
                    $comment->{editor},      $comment->{picture_keyword}
                )
            )
        );
        $memkey = [
            $journalu->{userid},
            "tdup:$journalu->{userid}:$itemid-$parent->{talkid}-$posterid-$md5_b64"
        ];
        $jtalkid = LJ::MemCache::get($memkey);
    }

    # they don't have a duplicate...
    unless ($jtalkid) {
        my ( $posteru, $kw ) = ( $comment->{u}, $comment->{picture_keyword} );

        # XXX do select and delete $talkprop{'picture_keyword'} if they're lying
        my $pic = LJ::Userpic->new_from_keyword( $posteru, $kw );
        delete $comment->{picture_keyword} unless $pic && $pic->state eq 'N';

        # put the post in the database
        my ( $ok, $talkid_or_err ) = enter_comment($comment);
        return ( 0, $talkid_or_err ) unless $ok;
        $jtalkid = $talkid_or_err;

        # save its identifying characteristics to protect against duplicates.
        LJ::MemCache::set( $memkey, $jtalkid + 0, time() + 60 * 10 );
    }

    # cluster tracking
    LJ::mark_user_active( $comment->{u}, 'comment' );

    DW::Stats::increment(
        'dw.action.comment.post',
        1,
        [
            "journal_type:" . $journalu->journaltype_readable,
            "poster_type:"
                . ( ref $comment->{u} ? $comment->{u}->journaltype_readable : 'anonymous' )
        ]
    );

    LJ::Hooks::run_hooks( 'new_comment', $journalu->{userid}, $itemid, $jtalkid )
        ;    # This hook is never registered by anything in -free or -nonfree. -NF

    return ( 1, $jtalkid );
}

# Does what it says on the tin.
# Expects a comment hashref from LJ::Talk::Post::prepare_and_validate_comment.
# Don't yolo one of these hashrefs unless you're in a test.
# returns (1, talkid) on success, (0, error) on fail
sub edit_comment {
    my ($comment) = @_;

    my $item     = $comment->{entry};
    my $journalu = $item->journal;

    my $comment_obj = LJ::Comment->new( $journalu, dtalkid => $comment->{editid} );

    my $remote = LJ::get_remote();
    my $edit_error;
    return ( 0, $edit_error ) unless $comment_obj->remote_can_edit( \$edit_error );

    my %props = (
        subjecticon      => $comment->{subjecticon},
        opt_preformatted => $comment->{preformat} ? 1 : 0,
        admin_post       => $comment->{admin_post} ? 1 : 0,
        editor           => $comment->{editor},
        edit_reason      => $comment->{editreason},
    );

    # set to undef if we have blank/0 values (set_props will delete these from the DB later)
    foreach ( keys %props ) { $props{$_} = undef unless $props{$_}; }

    my $pu = $comment_obj->poster;
    if ( $pu && $pu->userpic_have_mapid ) {
        $props{picture_mapid} = $pu->get_mapid_from_keyword( $comment->{picture_keyword} );
    }
    else {
        $props{picture_keyword} = $comment->{picture_keyword};
    }

    # set most of the props together
    $comment_obj->set_props(%props);

    # set edit time separately since it needs to be a raw value
    $comment_obj->set_prop_raw( edit_time => "UNIX_TIMESTAMP()" );

    # set poster IP separately since it has special conditions
    my $opt_logcommentips = $comment_obj->journal->opt_logcommentips;
    my $site_user_comment = $comment->{u} && $comment->{u}->is_person;
    if ( $opt_logcommentips eq "A"
        || ( $opt_logcommentips eq "S" && !$site_user_comment ) )
    {
        $comment_obj->set_poster_ip;
    }

    # set subject and body text
    $comment_obj->set_subject_and_body( $comment->{subject}, $comment->{body} );

    # If we need to rescreen the comment, do so now.
    my $state = $comment->{state} || "";
    if ( $state eq 'S' ) {
        LJ::Talk::screen_comment( $journalu, $item->jitemid, $comment_obj->jtalkid );
    }

    # cluster tracking
    LJ::mark_user_active( $pu, 'comment' );

    # fire events
    my @jobs;

    push @jobs, LJ::Event::JournalNewComment::Edited->new($comment_obj);

    if (@LJ::SPHINX_SEARCHD) {
        push @jobs,
            DW::Task::SphinxCopier->new(
            { userid => $journalu->id, jtalkid => $comment_obj->jtalkid, source => "commtedt" } );
    }

    DW::TaskQueue->dispatch(@jobs) if @jobs;

    DW::Stats::increment(
        'dw.action.comment.edit',
        1,
        [
            "journal_type:" . $journalu->journaltype_readable,
            "poster_type:" . $pu ? $pu->journaltype_readable : 'anonymous'
        ]
    );

    return ( 1, $comment_obj->jtalkid );
}

# given a journalu and jitemid, return 1 if the entry
# is over the maximum comments allowed.
sub over_maxcomments {
    my ( $journalu, $jitemid ) = @_;
    $journalu = LJ::want_user($journalu);
    $jitemid += 0;
    return 0 unless $journalu && $jitemid;

    my $count = LJ::Talk::get_replycount( $journalu, $jitemid );
    return ( $count >= $journalu->count_maxcomments ) ? 1 : 0;
}

# more anti-spammer rate limiting.  returns 1 if rate is okay, 0 if too fast.
sub check_rate {
    my ( $remote, $journalu ) = @_;

    # we require memcache to do rate limiting efficiently
    return 1 unless @LJ::MEMCACHE_SERVERS;

    # return right away if the account is suspended
    return 0 if $remote && ( $remote->is_suspended || $remote->is_deleted );

    # allow some users to be very aggressive commenters and authors. i.e. our bots.
    return 1
        if $remote
        and grep { $remote->username eq $_ } @LJ::NO_RATE_CHECK_USERS;

    my $ip  = LJ::get_remote_ip();
    my $now = time();
    my @watch;

    if ($remote) {

        # registered human (or human-impersonating robot)
        push @watch,
            [
            "talklog:$remote->{userid}", $LJ::RATE_COMMENT_AUTH || [ [ 200, 3600 ], [ 20, 60 ] ],
            ];
    }
    else {
        # anonymous, per IP address (robot or human)
        push @watch,
            [
            "talklog:$ip",
            $LJ::RATE_COMMENT_ANON || [ [ 300, 3600 ], [ 200, 1800 ], [ 150, 900 ], [ 15, 60 ] ]
            ];

        # anonymous, per journal.
        # this particular limit is intended to combat flooders, instead
        # of the other 'spammer-centric' limits.
        push @watch,
            [
            "talklog:anonin:$journalu->{userid}",
            $LJ::RATE_COMMENT_ANON || [ [ 300, 3600 ], [ 200, 1800 ], [ 150, 900 ], [ 15, 60 ] ]
            ];

        # throttle based on reports of spam
        push @watch,
            [ "spamreports:anon:$ip", $LJ::SPAM_COMMENT_RATE || [ [ 50, 86400 ], [ 10, 3600 ] ] ];
    }

WATCH:
    foreach my $watch (@watch) {
        my ( $key, $rates ) = ( $watch->[0], $watch->[1] );
        my $max_period = $rates->[0]->[1];

        my $log = LJ::MemCache::get($key) || "";

        # parse the old log
        my @times;
        if ( length($log) % 4 == 1 && substr( $log, 0, 1 ) eq $RATE_DATAVER ) {
            my $ct = ( length($log) - 1 ) / 4;
            for ( my $i = 0 ; $i < $ct ; $i++ ) {
                my $time = unpack( "N", substr( $log, $i * 4 + 1, 4 ) );
                push @times, $time if $time > $now - $max_period;
            }
        }

        # add this event unless we're throttling based on spamreports
        push @times, $now unless $key =~ /^spamreports/;

        # check rates
        foreach my $rate (@$rates) {
            my ( $allowed, $period ) = ( $rate->[0], $rate->[1] );
            my $events = scalar grep { $_ > $now - $period } @times;
            if ( $events > $allowed ) {

                last WATCH;
            }
        }

        # build the new log
        my $newlog = $RATE_DATAVER;
        foreach (@times) {
            $newlog .= pack( "N", $_ );
        }

        LJ::MemCache::set( $key, $newlog, $max_period );
    }

    return 1;
}

1;
