# 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::User;
use strict;
no warnings 'uninitialized';

use List::Util qw/ min /;

########################################################################
###  28. Userpic-Related Functions

=head2 Userpic-Related Functions

=head3 C<< $u->activate_userpics >>

Sets/unsets userpics as inactive based on account caps.

=cut

sub activate_userpics {
    my $u = shift;

    # this behavior is optional, but enabled by default
    return 1 if $LJ::ALLOW_PICS_OVER_QUOTA;

    return undef unless LJ::isu($u);

    # can't get a cluster read for expunged users since they are clusterid 0,
    # so just return 1 to the caller from here and act like everything went fine
    return 1 if $u->is_expunged;

    my $userid     = $u->userid;
    my $have_mapid = $u->userpic_have_mapid;

    # active / inactive lists
    my @active   = ();
    my @inactive = ();

    # get a database handle for reading/writing
    my $dbh  = LJ::get_db_writer();
    my $dbcr = LJ::get_cluster_def_reader($u);

    # select all userpics and build active / inactive lists
    return undef unless $dbcr;
    my $sth = $dbcr->prepare("SELECT picid, state FROM userpic2 WHERE userid=?");
    $sth->execute($userid);
    while ( my ( $picid, $state ) = $sth->fetchrow_array ) {
        next if $state eq 'X';    # expunged, means userpic has been removed from site by admins
        if ( $state eq 'I' ) {
            push @inactive, $picid;
        }
        else {
            push @active, $picid;
        }
    }

    # inactivate previously activated userpics
    my $allowed = $u->userpic_quota;
    if ( scalar @active > $allowed ) {
        my $to_ban = scalar @active - $allowed;

        # find first jitemid greater than time 2 months ago using rlogtime index
        # ($LJ::EndOfTime - UnixTime)
        my $jitemid = $dbcr->selectrow_array(
            "SELECT jitemid FROM log2 USE INDEX (rlogtime) "
                . "WHERE journalid=? AND rlogtime > ? LIMIT 1",
            undef, $userid, $LJ::EndOfTime - time() + 86400 * 60
        );

        # query all pickws in logprop2 with jitemid > that value
        my %count_kw = ();
        my $propid;
        if ($have_mapid) {
            $propid = LJ::get_prop( "log", "picture_mapid" )->{id};
        }
        else {
            $propid = LJ::get_prop( "log", "picture_keyword" )->{id};
        }
        my $sth =
            $dbcr->prepare( "SELECT value, COUNT(*) FROM logprop2 "
                . "WHERE journalid=? AND jitemid > ? AND propid=?"
                . "GROUP BY value" );
        $sth->execute( $userid, $jitemid || 0, $propid );
        while ( my ( $value, $ct ) = $sth->fetchrow_array ) {

            # keyword => count
            $count_kw{$value} = $ct;
        }

        my $values_in = join( ",", map { $dbh->quote($_) } keys %count_kw );

        # map pickws to picids for freq hash below
        my %count_picid = ();
        if ($values_in) {
            if ($have_mapid) {
                foreach my $mapid ( keys %count_kw ) {
                    my $picid = $u->get_picid_from_mapid($mapid);
                    $count_picid{$picid} += $count_kw{$mapid} if $picid;
                }
            }
            else {
                my $sth =
                    $dbcr->prepare( "SELECT k.keyword, m.picid FROM userkeywords k, userpicmap2 m "
                        . "WHERE k.keyword IN ($values_in) AND k.kwid=m.kwid AND k.userid=m.userid "
                        . "AND k.userid=?" );
                $sth->execute($userid);
                while ( my ( $keyword, $picid ) = $sth->fetchrow_array ) {

                    # keyword => picid
                    $count_picid{$picid} += $count_kw{$keyword};
                }
            }
        }

        # we're only going to ban the least used, excluding the user's default
        my @ban = (
            grep { $_ != $u->{defaultpicid} }
            sort { $count_picid{$a} <=> $count_picid{$b} } @active
        );

        @ban = splice( @ban, 0, $to_ban ) if @ban > $to_ban;
        my $ban_in = join( ",", map { $dbh->quote($_) } @ban );
        $u->do( "UPDATE userpic2 SET state='I' WHERE userid=? AND picid IN ($ban_in)",
            undef, $userid )
            if $ban_in;
    }

    # activate previously inactivated userpics
    if ( scalar @inactive && scalar @active < $allowed ) {
        my $to_activate = $allowed - @active;
        $to_activate = @inactive if $to_activate > @inactive;

        # take the $to_activate newest (highest numbered) pictures
        # to reactivated
        @inactive = sort @inactive;
        my @activate_picids = splice( @inactive, -$to_activate );

        my $activate_in = join( ",", map { $dbh->quote($_) } @activate_picids );
        if ($activate_in) {
            $u->do( "UPDATE userpic2 SET state='N' WHERE userid=? AND picid IN ($activate_in)",
                undef, $userid );
        }
    }

    # delete userpic info object from memcache
    LJ::Userpic->delete_cache($u);
    $u->clear_userpic_kw_map;

    return 1;
}

=head3 C<< $u->allpics_base >>

Return the base URL for the icons page.

=cut

sub allpics_base {
    return $_[0]->journal_base . "/icons";
}

=head3 C<< $u->clear_userpic_kw_map >>

Clears the internally cached mapping of userpics to keywords for this user.

=cut

sub clear_userpic_kw_map {
    $_[0]->{picid_kw_map} = undef;
}

=head3 C<< $u->expunge_userpic( $picid ) >>

Expunges a userpic so that the system will no longer deliver this userpic.

=cut

# If your site has off-site caching or something similar, you can also define
# a hook "expunge_userpic" which will be called with a picid and userid when
# a pic is expunged.
sub expunge_userpic {
    my ( $u, $picid ) = @_;
    $picid += 0;
    return undef unless $picid && LJ::isu($u);

    # get the pic information
    my $state;

    my $dbcm = LJ::get_cluster_master($u);
    return undef unless $dbcm && $u->writer;

    $state = $dbcm->selectrow_array( 'SELECT state FROM userpic2 WHERE userid = ? AND picid = ?',
        undef, $u->userid, $picid );
    return undef unless $state;            # invalid pic
    return $u->userid if $state eq 'X';    # already expunged

    # else now mark it
    $u->do( "UPDATE userpic2 SET state='X' WHERE userid = ? AND picid = ?",
        undef, $u->userid, $picid );
    return LJ::error($dbcm) if $dbcm->err;

# Since we don't clean userpicmap2 when we migrate to dversion 9, clean it here on expunge no matter the dversion.
    $u->do( "DELETE FROM userpicmap2 WHERE userid = ? AND picid = ?", undef, $u->userid, $picid );
    if ( $u->userpic_have_mapid ) {
        $u->do( "DELETE FROM userpicmap3 WHERE userid = ? AND picid = ? AND kwid=NULL",
            undef, $u->userid, $picid );
        $u->do( "UPDATE userpicmap3 SET picid = NULL WHERE userid = ? AND picid = ?",
            undef, $u->userid, $picid );
    }

    # now clear the user's memcache picture info
    LJ::Userpic->delete_cache($u);

    # call the hook and get out of here
    my @rval = LJ::Hooks::run_hooks( 'expunge_userpic', $picid, $u->userid );
    return ( $u->userid, map { $_->[0] } grep { $_ && @$_ && $_->[0] } @rval );
}

=head3 C<< $u->get_keyword_from_mapid( $mapid, %opts ) >>

Returns the keyword for the given mapid or undef if the mapid doesn't exist.

Arguments:

=over 4

=item mapid

=back

Additional options:

=over 4

=item redir_callback

Called if the mapping is redirected to another mapping with the following arguments

( $u, $old_mapid, $new_mapid )

=back

=cut

sub get_keyword_from_mapid {
    my ( $u, $mapid, %opts ) = @_;
    my $info = LJ::isu($u) ? $u->get_userpic_info : undef;
    return undef unless $info;
    return undef unless $u->userpic_have_mapid;

    $mapid = $u->resolve_mapid_redirects( $mapid, %opts );
    my $kw = $info->{mapkw}->{$mapid};
    return $kw;
}

=head3 C<< $u->get_mapid_from_keyword( $kw, %opts ) >>

Returns the mapid for a given keyword.

Arguments:

=over 4

=item kw

The keyword.

=back

Additional options:

=over 4

=item create

Should a mapid be created if one does not exist.

Default: 0

=back

=cut

sub get_mapid_from_keyword {
    my ( $u, $kw, %opts ) = @_;
    return 0 unless $u->userpic_have_mapid;

    my $info = LJ::isu($u) ? $u->get_userpic_info : undef;
    return 0 unless $info;

    my $mapid = $info->{kwmap}->{$kw};
    return $mapid if $mapid;

    # the silly "pic#2343" thing when they didn't assign a keyword, if we get here
    # we need to create it.
    if ( $kw =~ /^pic\#(\d+)$/ ) {
        my $picid = $1;
        return 0 unless $info->{pic}{$picid};                  # don't create rows for invalid pics
        return 0 unless $info->{pic}{$picid}{state} eq 'N';    # or inactive

        return $u->_create_mapid( undef, $picid );
    }

    return 0 unless $opts{create};

    return $u->_create_mapid( $u->get_keyword_id($kw), undef );
}

=head3 C<< $u->get_picid_from_keyword( $kw, $default ) >>

Returns the picid for a given keyword.

=over 4

=item kw

Keyword to look up.

=item default (optional)

Default: the users default userpic.

=back

=cut

sub get_picid_from_keyword {
    my ( $u, $kw, $default ) = @_;
    $default ||= ref $u ? $u->{defaultpicid} : 0;
    return $default unless defined $kw;

    my $info = LJ::isu($u) ? $u->get_userpic_info : undef;
    return $default unless $info;

    my $pr = $info->{kw}{$kw};

    # normal keyword
    return $pr->{picid} if $pr->{picid};

    # the silly "pic#2343" thing when they didn't assign a keyword
    if ( $kw =~ /^pic\#(\d+)$/ ) {
        my $picid = $1;
        return $picid if $info->{pic}{$picid};
    }

    return $default;
}

=head3 C<< $u->get_picid_from_mapid( $mapid, %opts ) >>

Returns the picid for a given mapid.

Arguments:

=over 4

=item mapid

=back

Additional options:

=over 4

=item default

Default: the users default userpic.

=item redir_callback

Called if the mapping is redirected to another mapping with the following arguments

( $u, $old_mapid, $new_mapid )

=back

=cut

sub get_picid_from_mapid {
    my ( $u, $mapid, %opts ) = @_;
    my $default = $opts{default} || ref $u ? $u->{defaultpicid} : 0;
    return $default unless $mapid;
    return $default unless $u->userpic_have_mapid;

    my $info = LJ::isu($u) ? $u->get_userpic_info : undef;
    return $default unless $info;

    $mapid = $u->resolve_mapid_redirects( $mapid, %opts );
    my $pr = $info->{mapid}{$mapid};

    return $pr->{picid} if $pr->{picid};

    return $default;
}

=head3 C<< $u->get_userpic_count >>

Return the number of userpics.

=cut

sub get_userpic_count {
    my $u     = shift or return undef;
    my $count = scalar LJ::Userpic->load_user_userpics($u);

    return $count;
}

=head3 C<< $u->get_userpic_info( $opts ) >>

Given a user, gets their userpic information

Arguments:

=over 4

=item opts

Hashref of options

Valid options:

=over 4

=item load_comments

=item load_urls

=item load_descriptions

=back

Returns a hashref with the following keys:

=over 4

=item comment

Maps a picid to a comment.
May not be present if load_comments was not specified.

=item description

Maps a picid to a description.
May not be present if load_descriptions was not specified.

=item kw

Maps a keyword to a pic hashref.

=item kwmap

Maps a keyword to a mapid.

=item map_redir

Maps a mapid to a diffrent mapid.

=item mapid

Maps a mapid to a pic hashref.

=item mapkw

Maps a mapid to a keyword.

=item pic

Maps a picid to a pic hashref.

=back

=back

=cut

# returns: hash of userpicture information;
#          for efficiency, we store the userpic structures
#          in memcache in a packed format.
# info: memory format:
#       [
#       version number of format,
#       userid,
#       "packed string", which expands to an array of {width=>..., ...}
#       "packed string", which expands to { 'kw1' => id, 'kw2' => id, ...}
#       series of 3 4-byte numbers, which expands to { mapid1 => id, mapid2 => id, ...}, as well as { mapid1 => mapid2 }
#       "packed string", which expands to { 'kw1' => mapid, 'kw2' => mapid, ...}
#       ]
sub get_userpic_info {
    my ( $u, $opts ) = @_;
    return undef unless LJ::isu($u) && $u->clusterid;
    my $mapped_icons = $u->userpic_have_mapid;

    # in the cache, cool, well unless it doesn't have comments or urls or descriptions
    # and we need them
    if ( my $cachedata = $LJ::CACHE_USERPIC_INFO{ $u->userid } ) {
        my $good = 1;
        $good = 0 if $opts->{load_comments}     && !$cachedata->{_has_comments};
        $good = 0 if $opts->{load_urls}         && !$cachedata->{_has_urls};
        $good = 0 if $opts->{load_descriptions} && !$cachedata->{_has_descriptions};

        return $cachedata if $good;
    }

    my $VERSION_PICINFO = 4;

    my $memkey = [ $u->userid, "upicinf:$u->{'userid'}" ];
    my ( $info, $minfo );

    if ( $minfo = LJ::MemCache::get($memkey) ) {

        # the pre-versioned memcache data was a two-element hash.
        # since then, we use an array and include a version number.

        if ( ref $minfo eq 'HASH'
            || $minfo->[0] != $VERSION_PICINFO )
        {
            # old data in the cache.  delete.
            LJ::MemCache::delete($memkey);
        }
        else {
            my ( undef, $picstr, $kwstr, $picmapstr, $kwmapstr ) = @$minfo;
            $info = {
                pic => {},
                kw  => {}
            };
            while ( length $picstr >= 7 ) {
                my $pic = { userid => $u->userid };
                ( $pic->{picid}, $pic->{width}, $pic->{height}, $pic->{state} ) = unpack "NCCA",
                    substr( $picstr, 0, 7, '' );
                $info->{pic}->{ $pic->{picid} } = $pic;
            }

            my ( $pos, $nulpos );
            $pos = $nulpos = 0;
            while ( ( $nulpos = index( $kwstr, "\0", $pos ) ) > 0 ) {
                my $kw = substr( $kwstr, $pos, $nulpos - $pos );
                my $id = unpack( "N", substr( $kwstr, $nulpos + 1, 4 ) );
                $pos = $nulpos + 5;                         # skip NUL + 4 bytes.
                $info->{kw}->{$kw} = $info->{pic}->{$id};
            }

            if ($mapped_icons) {
                if ( defined $picmapstr && defined $kwmapstr ) {
                    $pos = 0;
                    while ( $pos < length($picmapstr) ) {
                        my ( $mapid, $id, $redir ) =
                            unpack( "NNN", substr( $picmapstr, $pos, 12 ) );
                        $pos += 12;                         # 3 * 4 bytes.
                        $info->{mapid}->{$mapid}     = $info->{pic}{$id} if $id;
                        $info->{map_redir}->{$mapid} = $redir            if $redir;
                    }

                    $pos = $nulpos = 0;
                    while ( ( $nulpos = index( $kwmapstr, "\0", $pos ) ) > 0 ) {
                        my $kw = substr( $kwmapstr, $pos, $nulpos - $pos );
                        my $id = unpack( "N", substr( $kwmapstr, $nulpos + 1, 4 ) );
                        $pos = $nulpos + 5;                 # skip NUL + 4 bytes.
                        $info->{kwmap}->{$kw} = $id;
                        $info->{mapkw}->{$id} = $kw || "pic#" . $info->{mapid}->{$id}->{picid};
                    }
                }
                else {    # This user is on dversion 9, but the data isn't in memcache
                          # so force a db load
                    undef $info;
                }
            }
        }

        # Load picture comments
        if ( $opts->{load_comments} && $info ) {
            my $commemkey = [ $u->userid, "upiccom:" . $u->userid ];
            my $comminfo  = LJ::MemCache::get($commemkey);

            if ( defined($comminfo) ) {
                my ( $pos, $nulpos );
                $pos = $nulpos = 0;
                while ( ( $nulpos = index( $comminfo, "\0", $pos ) ) > 0 ) {
                    my $comment = substr( $comminfo, $pos, $nulpos - $pos );
                    my $id      = unpack( "N", substr( $comminfo, $nulpos + 1, 4 ) );
                    $pos                           = $nulpos + 5;    # skip NUL + 4 bytes.
                    $info->{pic}->{$id}->{comment} = $comment;
                    $info->{comment}->{$id}        = $comment;
                }
                $info->{_has_comments} = 1;
            }
            else {    # Requested to load comments, but they aren't in memcache
                      # so force a db load
                undef $info;
            }
        }

        # Load picture urls
        if ( $opts->{load_urls} && $info ) {
            my $urlmemkey = [ $u->userid, "upicurl:" . $u->userid ];
            my $urlinfo   = LJ::MemCache::get($urlmemkey);

            if ( defined($urlinfo) ) {
                my ( $pos, $nulpos );
                $pos = $nulpos = 0;
                while ( ( $nulpos = index( $urlinfo, "\0", $pos ) ) > 0 ) {
                    my $url = substr( $urlinfo, $pos, $nulpos - $pos );
                    my $id  = unpack( "N", substr( $urlinfo, $nulpos + 1, 4 ) );
                    $pos = $nulpos + 5;                  # skip NUL + 4 bytes.
                    $info->{pic}->{$id}->{url} = $url;
                }
                $info->{_has_urls} = 1;
            }
            else {    # Requested to load urls, but they aren't in memcache
                      # so force a db load
                undef $info;
            }
        }

        # Load picture descriptions
        if ( $opts->{load_descriptions} && $info ) {
            my $descmemkey = [ $u->userid, "upicdes:" . $u->userid ];
            my $descinfo   = LJ::MemCache::get($descmemkey);

            if ( defined($descinfo) ) {
                my ( $pos, $nulpos );
                $pos = $nulpos = 0;
                while ( ( $nulpos = index( $descinfo, "\0", $pos ) ) > 0 ) {
                    my $description = substr( $descinfo, $pos, $nulpos - $pos );
                    my $id          = unpack( "N", substr( $descinfo, $nulpos + 1, 4 ) );
                    $pos                               = $nulpos + 5;    # skip NUL + 4 bytes.
                    $info->{pic}->{$id}->{description} = $description;
                    $info->{description}->{$id}        = $description;
                }
                $info->{_has_descriptions} = 1;
            }
            else {    # Requested to load descriptions, but they aren't in memcache
                      # so force a db load
                undef $info;
            }
        }
    }

    my %minfocom;     # need this in this scope
    my %minfourl;
    my %minfodesc;
    unless ($info) {
        $info = {
            pic => {},
            kw  => {}
        };
        my ( $picstr, $kwstr, $predirstr, $kwmapstr );
        my $sth;
        my $dbcr = LJ::get_cluster_def_reader($u);
        my $db   = @LJ::MEMCACHE_SERVERS ? LJ::get_db_writer() : LJ::get_db_reader();
        return undef unless $dbcr && $db;

        $sth =
            $dbcr->prepare( "SELECT picid, width, height, state, userid, comment, url, description "
                . "FROM userpic2 WHERE userid=?" );
        $sth->execute( $u->userid );
        my @pics;
        while ( my $pic = $sth->fetchrow_hashref ) {
            next if $pic->{state} eq 'X';    # no expunged pics in list
            push @pics, $pic;
            $info->{pic}->{ $pic->{picid} } = $pic;
            $minfocom{ int( $pic->{picid} ) } = $pic->{comment}
                if $opts->{load_comments} && $pic->{comment};
            $minfourl{ int( $pic->{picid} ) } = $pic->{url}
                if $opts->{load_urls} && $pic->{url};
            $minfodesc{ int( $pic->{picid} ) } = $pic->{description}
                if $opts->{load_descriptions} && $pic->{description};
        }

        $picstr = join( '',
            map { pack( "NCCA", $_->{picid}, $_->{width}, $_->{height}, $_->{state} ) } @pics );

        if ($mapped_icons) {
            $sth = $dbcr->prepare(
"SELECT k.keyword, m.picid, m.mapid, m.redirect_mapid FROM userpicmap3 m LEFT JOIN userkeywords k ON "
                    . "( m.userid=k.userid AND m.kwid=k.kwid ) WHERE m.userid=?" );
        }
        else {
            $sth = $dbcr->prepare( "SELECT k.keyword, m.picid FROM userpicmap2 m, userkeywords k "
                    . "WHERE k.userid=? AND m.kwid=k.kwid AND m.userid=k.userid" );
        }
        $sth->execute( $u->{'userid'} );
        my %minfokw;
        my %picmap;
        my %kwmap;
        while ( my ( $kw, $id, $mapid, $redir ) = $sth->fetchrow_array ) {

            # used to be a bug that allowed these to get in.
            next if $kw =~ /[\n\r\0]/ || ( defined $kw && length($kw) == 0 );

            my $skip_kw = 0;
            if ($mapped_icons) {
                $picmap{$mapid} = [ int($id), int($redir) ];
                if ($redir) {
                    $info->{map_redir}->{$mapid} = $redir;
                }
                else {
                    unless ( defined $kw ) {
                        $skip_kw = 1;
                        $kw      = "pic#$id";
                    }
                    $info->{kwmap}->{$kw}    = $kwmap{$kw} = $mapid;
                    $info->{mapkw}->{$mapid} = $kw;
                }
            }

            next unless $info->{pic}->{$id};
            $info->{mapid}->{$mapid} = $info->{pic}->{$id} if $mapped_icons && $id;

            next if $skip_kw;
            $info->{kw}->{$kw} = $info->{pic}->{$id};
            $minfokw{$kw} = int($id);
        }
        $kwstr = join( '', map { pack( "Z*N", $_, $minfokw{$_} ) } keys %minfokw );
        if ($mapped_icons) {
            $predirstr = join( '', map { pack( "NNN", $_, @{ $picmap{$_} } ) } keys %picmap );
            $kwmapstr  = join( '', map { pack( "Z*N", $_, $kwmap{$_} ) } keys %kwmap );
        }

        $memkey = [ $u->{'userid'}, "upicinf:$u->{'userid'}" ];
        $minfo  = [ $VERSION_PICINFO, $picstr, $kwstr, $predirstr, $kwmapstr ];
        LJ::MemCache::set( $memkey, $minfo );

        if ( $opts->{load_comments} ) {
            $info->{comment} = \%minfocom;
            my $commentstr = join( '', map { pack( "Z*N", $minfocom{$_}, $_ ) } keys %minfocom );

            my $memkey = [ $u->userid, "upiccom:" . $u->userid ];
            LJ::MemCache::set( $memkey, $commentstr );

            $info->{_has_comments} = 1;
        }

        if ( $opts->{load_urls} ) {
            my $urlstr = join( '', map { pack( "Z*N", $minfourl{$_}, $_ ) } keys %minfourl );

            my $memkey = [ $u->userid, "upicurl:" . $u->userid ];
            LJ::MemCache::set( $memkey, $urlstr );

            $info->{_has_urls} = 1;
        }

        if ( $opts->{load_descriptions} ) {
            $info->{description} = \%minfodesc;
            my $descstring = join( '', map { pack( "Z*N", $minfodesc{$_}, $_ ) } keys %minfodesc );

            my $memkey = [ $u->userid, "upicdes:" . $u->userid ];
            LJ::MemCache::set( $memkey, $descstring );

            $info->{_has_descriptions} = 1;
        }
    }

    $LJ::CACHE_USERPIC_INFO{ $u->userid } = $info;
    return $info;
}

=head3 C<< $u->get_userpic_kw_map >>

Gets a mapping from userpic ids to keywords for this User.

=cut

sub get_userpic_kw_map {
    my ($u) = @_;

    return $u->{picid_kw_map} if $u->{picid_kw_map};    # cache

    my $picinfo  = $u->get_userpic_info( { load_comments => 0 } );
    my $keywords = {};
    foreach my $keyword ( keys %{ $picinfo->{kw} } ) {
        my $picid = $picinfo->{kw}->{$keyword}->{picid};
        $keywords->{$picid} = [] unless $keywords->{$picid};
        push @{ $keywords->{$picid} }, $keyword
            if defined $keyword && $picid && $keyword !~ m/^pic\#(\d+)$/;
    }

    return $u->{picid_kw_map} = $keywords;
}

=head3 C<< $u->resolve_mapid_redirects( $mapid, %opts ) >>

Resolve any mapid redirect, guarding against any redirect loops.

Returns: new map id, or 0 if the mapping cannot be resolved.

Arguments:

=over 4

=item mapid

=back

Additional options:

=over 4

=item redir_callback

Called if the mapping is redirected to another mapping with the following arguments

( $u, $old_mapid, $new_mapid )

=back

=cut

sub resolve_mapid_redirects {
    my ( $u, $mapid, %opts ) = @_;

    my $info = LJ::isu($u) ? $u->get_userpic_info : undef;
    return 0 unless $info;

    my %seen    = ( $mapid => 1 );
    my $orig_id = $mapid;

    while ( $info->{map_redir}->{$mapid} ) {
        $orig_id = $mapid;
        $mapid   = $info->{map_redir}->{$mapid};

        # To implement lazy updating or the like
        $opts{redir_callback}->( $u, $orig_id, $mapid ) if $opts{redir_callback};

        # This should never happen, but am checking it here mainly in case
        # never *does* happen, so we don't hang the web process with an endless loop.
        if ( $seen{$mapid}++ ) {
            warn( "userpicmap3 redirectloop for " . $u->id . " on mapid " . $mapid );
            return 0;
        }
    }

    return $mapid;
}

=head3 C<< $u->userpic >>

Returns LJ::Userpic for default userpic, if it exists.

=cut

sub userpic {
    my $u = shift;
    return undef unless $u->{defaultpicid};
    return LJ::Userpic->new( $u, $u->{defaultpicid} );
}

=head3 C<< $u->userpic_have_mapid >>

Returns true if the userpicmap keyword mappings have a mapid column ( dversion 9 or higher )

=cut

# FIXME: This probably should be userpics_use_mapid
sub userpic_have_mapid {
    return $_[0]->dversion >= 9;
}

=head3 C<< $u->userpic_quota >>

Returns the number of userpics the user can upload (base account type cap + bonus slots purchased)

=cut

sub userpic_quota {
    my $u  = shift or return undef;
    my $ct = $u->get_cap('userpics');
    $ct += $u->prop('bonus_icons') || 0
        if $u->is_paid;    # paid accounts get bonus icons
    return min( $ct, $LJ::USERPIC_MAXIMUM );
}

# Intentionally no POD here.
# This is an internal helper method
# takes a $kwid and $picid ( either can be undef )
# and creates a mapid row for it
sub _create_mapid {
    my ( $u, $kwid, $picid ) = @_;
    return 0 unless $u->userpic_have_mapid;

    my $mapid = LJ::alloc_user_counter( $u, 'Y' );
    $u->do( "INSERT INTO userpicmap3 (userid, mapid, kwid, picid) VALUES (?,?,?,?)",
        undef, $u->id, $mapid, $kwid, $picid );
    return 0 if $u->err;

    LJ::Userpic->delete_cache($u);
    $u->clear_userpic_kw_map;

    return $mapid;
}

# <LJFUNC>
# name: LJ::icon_keyword_menu
# class: web
# des: Gets all userpics for a given user, separated by keyword. Use this when
#      building a drop-down icons menu.
# args: user
# des-user: a user object.
# returns: An arrayref of hashrefs like:
#          [{value => ..., text => ..., data => { url => ..., description => ... }}, ...]
#          which can be passed directly to the form.select() template helper.
#          Includes an item for each keyword (thus duplicating icons with
#          multiple keywords), and an item for the default userpic. If no
#          userpics or if user is undefined, returns an empty array.
# </LJFUNC>
sub icon_keyword_menu {
    my ($user) = @_;

    return [] unless ($user);

    my @icons = grep { !( $_->inactive || $_->expunged ) } LJ::Userpic->load_user_userpics($user);

    return [] unless (@icons);

    # Get a sorted array of { keyword => "...", userpic => userpic_object } hashrefs:
    @icons = LJ::Userpic->separate_keywords( \@icons );

    # Sort out the default icon -- either it's a real one, or it's nothing
    # and we should use a placeholder image in previews.
    my $default_icon = $user->userpic;    # userpic object or nothing
    my $default_icon_url =
          $default_icon
        ? $default_icon->url
        : ( $LJ::IMGPREFIX . $LJ::Img::img{nouserpic_sitescheme}->{src} );

    # Finally, return the expected format for the form.select() template helper,
    # including an item for the default icon:
    return [
        {
            value => "",
            text  => LJ::Lang::ml('entryform.opt.defpic'),
            data  => {
                url         => $default_icon_url,
                description => LJ::Lang::ml('entryform.opt.defpic'),
            },
        },
        map {
            {
                value => $_->{keyword},
                text  => $_->{keyword},
                data  => {
                    url         => $_->{userpic}->url,
                    description => $_->{userpic}->description || $_->{keyword},
                },
            }
        } @icons
    ];
}

1;
