#!/usr/bin/perl
#
# DW::VirtualGift - Provide virtual gifts for users
#
# Authors:
#      Jen Griffin <kareila@livejournal.com>
#
# Copyright (c) 2010-2013 by Dreamwidth Studios, LLC.
#
# This program is free software; you may redistribute it and/or modify it under
# the same terms as Perl itself. For a copy of the license, please reference
# 'perldoc perlartistic' or 'perldoc perlgpl'.

package DW::VirtualGift;

use strict;
use warnings;

use constant PROPLIST => qw/ vgiftid name created_t creatorid active
    approved approved_by approved_why
    custom featured description cost
    mime_small mime_large /;

# NOTE: remember to update &validate if you add new props

use base 'LJ::MemCacheable';

# MemCacheable methods ###################################
*_memcache_id                = \&id;            #
*_memcache_hashref_to_object = \&absorb_row;    #
sub _memcache_key_prefix   { 'vgift_obj' }                 #
sub _memcache_expires      { 24 * 3600 }                   #
sub _memcache_stored_props { return ( '1', PROPLIST ) }    #

# end MemCacheable methods ###############################

use Digest::MD5 qw/ md5_hex /;
use DW::BlobStore;

use LJ::Global::Constants;

# Because events use this module, Perl warns about redefined subroutines.
{
    no warnings 'redefine';
    use LJ::Event::VgiftApproved;
}

# TABLE OF CONTENTS
#
# 1. Constructor methods (anything that modifies db values)
# 2. Accessor methods (simple db reads and booleans)
# 3. Memcache methods (for referencing & expiring keys)
# 4. Validation methods (for checking user-supplied data)
# 5. Aggregate methods (for mass lookups)
# 6. End-user display methods (making things look purty)
# 7. Notification methods (let people know about things)

# Transaction methods moved to DW::VirtualGiftTransaction

# 1. Constructor methods
sub new {
    my ( $class, $id ) = @_;
    return undef if !$id || $id !~ /^\d+$/;
    my $self = { vgiftid => $id };
    bless $self, ( ref $class ? ref $class : $class );
    return $self;
}

sub _init {

    # This should only be called by the "create" method.
    # It grabs an ID for the new vgift before calling the "new" method.
    my ( $class, $err ) = @_;
    if ( ref $class ) {
        $$err = LJ::Lang::ml('vgift.error.init.reuse') if $err;
        return undef;
    }

    my $vgiftid = LJ::alloc_global_counter('V');
    unless ($vgiftid) {
        $$err = LJ::Lang::ml('vgift.error.init.alloc') if $err;
        return undef;
    }

    # we have an id, now initialize the object
    return $class->new($vgiftid);
}

sub create {

    # opts are values for object properties as defined in PROPLIST.
    # also allowed: 'error' which should be a scalar reference;
    # 'img_small' & 'img_large' which should contain raw
    # image data to be stored in media storage (blobstore).
    my ( $class, %opts ) = @_;
    my %vg;    # hash for storing row data
    foreach (PROPLIST) {
        $vg{$_} = $opts{$_} if defined $opts{$_};

        # translate Perl nulls into MySQL nulls
        $vg{$_} = undef if exists $vg{$_} && $vg{$_} eq '';
    }

    # don't allow created_t to be overridden
    $vg{created_t} = time;

    # enforce active/approved defaults for new gifts
    if ( $vg{custom} && $vg{custom} eq 'Y' ) {
        $vg{active}   = 'Y';
        $vg{approved} = 'N';
    }
    else {
        delete @vg{qw( active approved )};
    }

    # name is required
    unless ( $vg{name} ) {
        ${ $opts{error} } = LJ::Lang::ml('vgift.error.create.noname');
        return undef;
    }

    # name must be unique
    my $dbr = LJ::get_db_reader();
    my $exists =
        $dbr->selectrow_array( "SELECT name FROM vgift_ids " . "WHERE name=?", undef, $vg{name} );
    die $dbr->errstr if $dbr->err;

    if ($exists) {
        ${ $opts{error} } = LJ::Lang::ml('vgift.error.create.samename');
        return undef;
    }
    undef $dbr;    # release handle

    # creatorid defaults to the logged in user if there is one
    $vg{creatorid} = LJ::get_remote() unless defined $vg{creatorid};
    $vg{creatorid} = LJ::want_userid( $vg{creatorid} );

    # validate input
    return undef unless $class->validate_all( $opts{error}, \%vg );

    # now that we're reasonably certain we have good data,
    # grab an id and get to work
    my $self = $class->_init( $opts{error} ) or return undef;
    $vg{vgiftid} = $self->id;

    # save pictures here, after getting id but before updating DB
    return undef unless $self->_savepics( \%vg, %opts );

    # construct SQL statement
    my $dbh   = LJ::get_db_writer();
    my $props = join( ', ', keys %vg );
    my $qs    = join( ', ', map { '?' } keys %vg );
    $dbh->do( "INSERT INTO vgift_ids ($props) VALUES ($qs)", undef, values %vg );
    die $dbh->errstr if $dbh->err;

    # initialize this gift in the vgift_counts table
    $dbh->do( "INSERT INTO vgift_counts (vgiftid,count) VALUES (?,0)", undef, $self->id );
    die $dbh->errstr if $dbh->err;

    $self->_expire_aggregate_keys;
    return $self->absorb_row( \%vg );
}

sub _savepic {
    my ( $self, $size, $data ) = @_;
    return undef unless $data && $self->id;

    # img_mogkey checks $size, don't need to explicitly check here
    return undef unless my $key = $self->img_mogkey($size);

    my %mime = (
        JPG => 'image/jpeg',
        GIF => 'image/gif',
        PNG => 'image/png',
    );
    my ( undef, undef, $filetype ) = Image::Size::imgsize($data);
    return undef unless $mime{$filetype};

    return undef
        unless DW::BlobStore->store( vgifts => $key, $data );

    return $mime{$filetype};
}

sub _savepics {
    my ( $self, $ref, %opts ) = @_;
    return undef unless $self->id;
    return undef unless ref $ref eq 'HASH';

    my $mime_small = $self->_savepic( 'small', $opts{img_small} );
    if ( $opts{img_small} && !$mime_small ) {
        ${ $opts{error} } = LJ::Lang::ml( 'vgift.error.savepics', { size => 'small' } );
        return undef;
    }
    my $mime_large = $self->_savepic( 'large', $opts{img_large} );
    if ( $opts{img_large} && !$mime_large ) {
        ${ $opts{error} } = LJ::Lang::ml( 'vgift.error.savepics', { size => 'large' } );
        return undef;
    }
    $ref->{mime_small} = $mime_small if $mime_small;
    $ref->{mime_large} = $mime_large if $mime_large;

    return 1;
}

sub edit {

    # opts are values for object properties as defined in PROPLIST.
    # also allowed: 'error' which should be a scalar reference;
    # 'img_small' & 'img_large' which should contain raw
    # image data to be stored in media storage (blobstore).
    my ( $self, %opts ) = @_;
    return undef unless $self->id;

    my %vg;    # hash for storing row data
    foreach (PROPLIST) {
        $vg{$_} = $opts{$_} if defined $opts{$_};

        # translate Perl nulls into MySQL nulls
        $vg{$_} = undef if exists $vg{$_} && $vg{$_} eq '';
    }

    # don't allow created_t or vgiftid to be overridden
    delete @vg{qw( created_t vgiftid )};

    $vg{creatorid} = LJ::want_userid( $vg{creatorid} )
        if defined $vg{creatorid};

    # save pictures first
    return undef unless $self->_savepics( \%vg, %opts );

    return $self unless %vg;    # no DB updates

    # validate input
    return undef unless $self->validate_all( $opts{error}, \%vg );

    # expire aggregate keys based on current values
    # (we expire again below, but that is based on the new values)
    $self->_expire_aggregate_keys;

    # construct SQL statement with new values
    my $dbh   = LJ::get_db_writer();
    my @keys  = keys %vg;
    my $props = join( ', ', map { "$_=?" } @keys );
    $dbh->do( "UPDATE vgift_ids SET $props WHERE vgiftid=?", undef, values %vg, $self->id );
    die $dbh->errstr if $dbh->err;

    # update objects in memory
    $self->{$_} = $vg{$_} foreach @keys;
    $self->_remove_from_memcache;    # LJ::MemCacheable
    $self->_expire_relevant_keys(@keys);

    return $self;
}

sub mark_active   { $_[0]->edit( active => 'Y' ) }
sub mark_inactive { $_[0]->edit( active => 'N' ) }

sub mark_sold {
    my ($self) = @_;
    return undef unless $self->id;

    my $dbh = LJ::get_db_writer();
    $dbh->do( "UPDATE vgift_counts SET count=count+1 WHERE vgiftid=?", undef, $self->id );
    die $dbh->errstr if $dbh->err;

    LJ::MemCache::delete( $self->num_sold_memkey );
    return $self;
}

sub tags {

    # taglist is a comma separated string of tagnames.
    # opts allowed: 'error' which should be a scalar reference;
    # 'autovivify' boolean allowing new tags to be created in the DB.
    # returns array of tagnames (or arrayref in scalar context)
    my ( $self, $taglist, %opts ) = @_;
    return undef unless my $id = $self->id;
    return undef if $self->is_custom;    # can't tag custom gifts
    my $autovivify = $opts{autovivify};

    my $error = sub {
        ${ $opts{error} } = shift if $opts{error};
        return undef;
    };

    my $tagnames;

    if ( defined $taglist ) {            # save new tags
                                         # taglist can be an arrayref or a comma separated string
        my @newtags =
            ref $taglist eq 'ARRAY'
            ? @$taglist
            : LJ::interest_string_to_list($taglist);

        # vgift tags are similar enough to interests that we can reuse code
        unless (@newtags) {              # just wipe existing tags and return
            $self->_tagwipe;
            return wantarray ? () : [];
        }

        # make sure the tags we've specified are valid
        my @valid_tags;
        foreach my $tag (@newtags) {
            my ( $bytes, $chars ) = LJ::text_length($tag);
            next if $bytes > LJ::BMAX_SITEKEYWORD;
            next if $chars > LJ::CMAX_SITEKEYWORD;
            next if $tag =~ /[\<\>]/;
            push @valid_tags, $tag;
        }

        my %invalid_tags = map { $_ => 1 } @newtags;
        delete @invalid_tags{@valid_tags};

        if (%invalid_tags) {
            return $error->(
                LJ::Lang::ml(
                    'vgift.error.tags.invalid',
                    { taglist => $self->display_taglist( [ keys %invalid_tags ] ) }
                )
            );
        }

        # this shouldn't be possible, but just in case...
        return $error->( LJ::Lang::ml('vgift.error.tags.novalidtags') )
            unless @valid_tags;

        $tagnames = \@valid_tags;    # save for later

        # At this point we have the list of tag names to return,
        # but we still need to store the tag ids in the database.

        my $dbr    = LJ::get_db_reader();
        my $qs     = join( ', ', map { '?' } @valid_tags );
        my $dbdata = $dbr->selectall_arrayref(
            "SELECT keyword, kwid FROM sitekeywords " . "WHERE keyword IN ($qs)",
            undef, @valid_tags );
        die $dbr->errstr if $dbr->err;

        my %dbtags = map { $_->[0] => $_->[1] } @$dbdata;
        foreach my $tag (@valid_tags) {
            next if $dbtags{$tag};

            # try to create the tag if it didn't already exist
            my $tagid = LJ::get_sitekeyword_id( $tag, $autovivify, allowmixedcase => 1 );
            return $error->( LJ::Lang::ml( 'vgift.error.tags.create', { tag => LJ::ehtml($tag) } ) )
                unless $tagid;
            $dbtags{$tag} = $tagid;
        }

        # delete previous tags & clear memcached tag data
        $self->_tagwipe;

        # construct SQL statement for adding new tags
        my $dbh    = LJ::get_db_writer();
        my @tagids = values %dbtags;
        my $qps    = join( ', ', map { '(?,?)' } @tagids );
        my @vals   = map { ( $id, $_ ) } @tagids;
        $dbh->do( "INSERT INTO vgift_tags (vgiftid, tagid) VALUES $qps", undef, @vals );
        die $dbh->errstr if $dbh->err;

    }
    else {    # fetch existing tags
        my $tags = LJ::MemCache::get( $self->taglist_memkey );
        if ( $tags && ref $tags eq "ARRAY" ) {
            return wantarray ? @$tags : $tags;    # fast path out
        }
        my $dbr = LJ::get_db_reader();
        $tagnames = $dbr->selectcol_arrayref( "SELECT keyword FROM sitekeywords WHERE kwid IN "
                . "(SELECT tagid FROM vgift_tags WHERE vgiftid=$id)" );
        die $dbr->errstr if $dbr->err;
    }

    LJ::MemCache::set( $self->taglist_memkey, $tagnames, 3600 * 24 );
    return wantarray ? @$tagnames : $tagnames;
}

sub _tagwipe {
    my ( $self, $tagid ) = @_;
    return undef unless my $id = $self->id;

    # Acts on a single gift.  Remove $tagid from this gift,
    # or if no $tagid specified, remove ALL tags from this gift.

    my $dbh = LJ::get_db_writer();
    if ($tagid) {
        $dbh->do( "DELETE FROM vgift_tags WHERE vgiftid=$id AND tagid=?", undef, $tagid );
    }
    else {
        $dbh->do("DELETE FROM vgift_tags WHERE vgiftid=$id");
    }
    die $dbh->errstr if $dbh->err;
    $self->_expire_taglist_keys;
    return 1;
}

sub remove_tag_by_id {
    my ( $self, $tagid ) = @_;
    return undef unless $tagid && $tagid =~ /^\d+$/;
    return $self->_tagwipe($tagid);
}

sub alter_tag {
    my ( $self, $tagname, $newname ) = @_;

    # For every gift that has $tagname, remove that tag.
    # If $newname is provided, replace $tagname with $newname.

    my $oldid = $self->get_tagid($tagname);
    return undef unless $oldid;

    # We need to cache @vgs here before we do the SQL update,
    # so that we remember which gifts we were acting on
    # once the tags have been rewritten in the database.

    my @vgs = $self->list_tagged_with($tagname);
    my $dbh = LJ::get_db_writer();

    if ($newname) {
        my $newid = $self->create_tag($newname);
        return undef unless $newid;

        $dbh->do("UPDATE vgift_tags SET tagid=$newid WHERE tagid=$oldid");
        die $dbh->errstr if $dbh->err;
        $dbh->do("UPDATE vgift_tagpriv SET tagid=$newid WHERE tagid=$oldid");
        die $dbh->errstr if $dbh->err;

    }
    else {
        $dbh->do("DELETE FROM vgift_tags WHERE tagid=$oldid");
        die $dbh->errstr if $dbh->err;
        $dbh->do("DELETE FROM vgift_tagpriv WHERE tagid=$oldid");
        die $dbh->errstr if $dbh->err;
    }

    $self->_expire_taglist_keys(@vgs);
    return 1;
}

sub create_tag {
    my ( $self, $tagname ) = @_;
    return LJ::get_sitekeyword_id( $tagname, 1, allowmixedcase => 1 );
}

sub _addremove_tagpriv {
    my ( $self, $sql, $tagname, $privname, $arg ) = @_;
    return undef unless $sql && $tagname && $privname;
    my $tagid = $self->get_tagid($tagname)      or return undef;
    my $prlid = $self->validate_priv($privname) or return undef;

    my $dbh = LJ::get_db_writer();
    $dbh->do( $sql, undef, $tagid, $prlid, $arg );
    die $dbh->errstr if $dbh->err;
    return 1;
}

sub add_priv_to_tag {
    my $self = shift;
    return $self->_addremove_tagpriv(
        "INSERT IGNORE INTO vgift_tagpriv (tagid, prlid, arg)" . " VALUES (?,?,?)", @_ );
}

sub remove_priv_from_tag {
    my $self = shift;
    return $self->_addremove_tagpriv(
        "DELETE FROM vgift_tagpriv WHERE tagid=? AND prlid=?" . " AND arg=?", @_ );
}

sub delete {
    my ( $self, $u ) = @_;
    return undef unless my $id = $self->id;
    $u = $self->creator unless LJ::isu($u);
    return undef unless $self->can_be_deleted_by($u);

    # delete pictures from storage
    DW::BlobStore->delete( vgifts => $self->img_mogkey('large') );
    DW::BlobStore->delete( vgifts => $self->img_mogkey('small') );

    # wipe the relevant rows from the database
    $self->_tagwipe;
    my $dbh = LJ::get_db_writer();
    $dbh->do("DELETE FROM vgift_ids WHERE vgiftid=$id");
    die $dbh->errstr if $dbh->err;
    $dbh->do("DELETE FROM vgift_counts WHERE vgiftid=$id");
    die $dbh->errstr if $dbh->err;

    # wipe the relevant keys from memcache
    LJ::MemCache::delete( $self->num_sold_memkey );
    $self->_expire_relevant_keys;
    $self->_remove_from_memcache;    # LJ::MemCacheable

    return 1;
}

# 2. Accessor methods
sub id { return $_[0]->{vgiftid} }
*vgiftid = \&id;

sub name              { return $_[0]->_access('name') || ''; }
sub name_ehtml        { return LJ::ehtml( $_[0]->name ); }
sub description       { return $_[0]->_access('description') || ''; }
sub description_ehtml { return LJ::ehtml( $_[0]->description ); }

sub cost      { return $_[0]->_access('cost')      || 0 }
sub active    { return $_[0]->_access('active')    || 'N' }
sub custom    { return $_[0]->_access('custom')    || 'N' }
sub featured  { return $_[0]->_access('featured')  || 'N' }
sub creatorid { return $_[0]->_access('creatorid') || 0 }
sub created_t { return $_[0]->_access('created_t') }

sub creator     { return LJ::load_userid( $_[0]->creatorid ) }
sub is_inactive { return $_[0]->active eq 'N' ? 1 : 0 }
sub is_active   { return $_[0]->active eq 'Y' ? 1 : 0 }
sub is_custom   { return $_[0]->custom eq 'Y' ? 1 : 0 }
sub is_featured { return $_[0]->featured eq 'Y' ? 1 : 0 }
sub is_free     { return $_[0]->cost ? 0 : 1 }

sub approved     { return $_[0]->_access('approved')     || '' }
sub approved_by  { return $_[0]->_access('approved_by')  || '' }
sub approved_why { return $_[0]->_access('approved_why') || '' }
sub is_approved { return $_[0]->approved eq 'Y' ? 1 : 0 }
sub is_rejected { return $_[0]->approved eq 'N' ? 1 : 0 }
sub is_queued   { return $_[0]->approved        ? 0 : 1 }
sub approver    { return LJ::load_userid( $_[0]->approved_by ) }

sub img_small      { return $_[0]->_loadpic('small') }
sub img_large      { return $_[0]->_loadpic('large') }
sub img_small_html { return $_[0]->_loadpic_html('small') }
sub img_large_html { return $_[0]->_loadpic_html('large') }
sub mime_small     { return $_[0]->_access('mime_small') }
sub mime_large     { return $_[0]->_access('mime_large') }

sub mime_type {
    my ( $self, $size ) = @_;
    return undef unless $size;
    return $self->mime_small if $size eq 'small';
    return $self->mime_large if $size eq 'large';
    return undef;    # invalid size
}

sub _access {
    my ( $self, $prop ) = @_;
    return undef unless $prop = lc $prop;
    return $self->{$prop} if defined $self->{$prop};
    $self->_load;
    return $self->{$prop};
}

sub _load {
    my $self = shift;
    return undef unless $self->id;

    return $self if $self->{_loaded};              # from absorb_row
    return $self if $self->_load_from_memcache;    # LJ::MemCacheable

    # find row in database
    my $dbr   = LJ::get_db_reader();
    my $props = join( ', ', PROPLIST );

    my $row =
        $dbr->selectrow_hashref( "SELECT $props FROM vgift_ids WHERE vgiftid=?", undef, $self->id );
    die $dbr->errstr if $dbr->err;
    return undef unless $row;

    # store retrieved data
    $self->absorb_row($row);
    $self->_store_to_memcache;    # LJ::MemCacheable

    return $self;
}

sub absorb_row {
    my ( $self, $row ) = @_;
    return undef unless $row;

    $self->{$_} = $row->{$_} foreach PROPLIST;
    $self->{_loaded} = 1;
    return $self;
}

sub _loadpic {
    my ( $self, $size ) = @_;

    return undef unless my $id = $self->id;
    return undef unless $self->mime_type($size);

    return "$LJ::SITEROOT/vgift/$id/$size";
}

sub _loadpic_html {
    my ( $self, $size ) = @_;

    return '' unless $size && $size =~ /^(small|large)$/;
    return '' unless $self->id;
    my $url = $self->_loadpic($size);
    return LJ::Lang::ml( 'vgift.error.loadpic', { size => $size } )
        unless $url;

    my $name = $self->name_ehtml;
    my $desc = $self->description_ehtml;
    return "<img alt='$desc' title='$name' src='$url' />";
}

sub img_mogkey {
    my ( $self, $size ) = @_;
    return undef unless $size && $size =~ /^(small|large)$/;
    return undef unless my $id = $self->id;
    return "vgift_img_$size:$id";
}

# tagnames and interests are both in sitekeywords
sub get_tagname { return LJ::get_interest( $_[1] ) }

sub get_tagid { return LJ::get_sitekeyword_id( $_[1], 0, allowmixedcase => 1 ) }

sub can_be_approved_by {
    my ( $self, $u ) = @_;
    $u = LJ::want_user($u) or return undef;

    # creators can't approve their own gifts
    return 0 if $u->equals( $self->creator );

    # otherwise, same privileges as for edits
    return $self->can_be_edited_by($u);
}

sub can_be_edited_by {
    my ( $self, $u ) = @_;

    # don't allow editing of gifts that are active in the shop
    return 0 if $self->is_active;

    $u = LJ::want_user($u) or return undef;

    # creators can edit their own inactive gifts
    return 1 if $u->equals( $self->creator );

    # siteadmins can edit any inactive gift
    return 1 if $u->has_priv( 'siteadmin', 'vgifts' );

    return 0;
}

sub can_be_deleted_by {
    my $self = shift;

    # if the vgift has been purchased, don't allow
    return 0 if $self->num_sold;

    # otherwise, same privileges as for edits
    return $self->can_be_edited_by(@_);
}

sub checksum {
    my ($self) = @_;
    return unless $self->_load;

    # generate a checksum based on attribute values
    my @attrvals;
    foreach my $prop (PROPLIST) {
        push @attrvals, $self->{$prop} || 'NULL';
    }

    foreach my $size (qw( large small )) {
        my $data = DW::BlobStore->retrieve( vgifts => $self->img_mogkey($size) );
        push @attrvals, ref $data eq 'SCALAR' ? $$data : 'NULL';
    }

    return md5_hex( join ' ', @attrvals );
}

sub created_ago_text {
    my ($self) = @_;
    return '' unless $self->id;
    return LJ::diff_ago_text( $self->created_t );
}

sub is_untagged {
    my ($self) = @_;
    my $id = $self->id or return undef;
    foreach ( $self->list_untagged ) {
        return 1 if $id == $_->id;
    }
    return 0;    # not in the untagged list
}

sub num_sold {
    my ($self) = @_;
    my $id     = $self->id or return undef;
    my $count  = LJ::MemCache::get( $self->num_sold_memkey );
    return $count if defined $count;

    # check db if not in cache
    my $dbr = LJ::get_db_reader();
    $count = $dbr->selectrow_array("SELECT count FROM vgift_counts WHERE vgiftid=$id") || 0;
    die $dbr->errstr if $dbr->err;

    LJ::MemCache::set( $self->num_sold_memkey, $count, 3600 * 24 );
    return $count;
}

# 3. Memcache methods
sub _expire_relevant_keys {

    # this is called from delete/edit to expire specific keys
    # relevant to the particular object being acted on.
    my ( $self, @props ) = @_;
    return undef unless $self->id;
    @props = PROPLIST unless @props;
    my %prop = map { $_ => 1 } @props;

    if ( $prop{mime_small} ) {

        # expire memcache for img_small (set in Apache::LiveJournal)
        LJ::MemCache::delete( $self->img_memkey('small') );
    }

    if ( $prop{mime_large} ) {

        # expire memcache for img_large (set in Apache::LiveJournal)
        LJ::MemCache::delete( $self->img_memkey('large') );
    }

    return $self->_expire_aggregate_keys(@props);
}

sub _expire_aggregate_keys {

    # this is called from create/edit to expire aggregate keys
    # based on specified values, or from _expire_relevant_keys.
    my ( $self, @props ) = @_;
    return undef unless $self->id;
    @props = PROPLIST unless @props;
    my %prop = map { $_ => 1 } @props;

    if ( $prop{creatorid} ) {

        # expire memcache for list_created_by
        LJ::MemCache::delete( $self->created_by_memkey );
    }

    if ( $prop{creatorid} || $prop{active} || $prop{approved} ) {

        # expire memcache for fetch_creatorcounts
        LJ::MemCache::delete( $self->creatorcounts_memkey );
    }

    return $self;
}

sub _expire_taglist_keys {

    # this is called from _tagwipe to expire tag-related keys.
    my ( $self, @vgs ) = @_;    # may pass in additional vgift objects
    @vgs = ($self) unless @vgs;
    foreach (@vgs) {
        next unless $_->id;

        # expire memcache for vgift list of tags
        LJ::MemCache::delete( $_->taglist_memkey );
    }

    # the rest are aggregate and only need to be expired once

    # expire memcache for fetch_tagcounts methods
    LJ::MemCache::delete( $self->tagcounts_approved_memkey );
    LJ::MemCache::delete( $self->tagcounts_active_memkey );

    # expire memcache for list_untagged
    LJ::MemCache::delete( $self->untagged_memkey );

    # expire memcache for list_nonpriv_tags
    LJ::MemCache::delete( $self->nonpriv_tags_memkey );

    # we can't force expiry of all individual user taglists
    # or tagid lists - these are uncached every few minutes

    return $self;
}

sub img_memkey {
    my ( $self, $size ) = @_;
    return undef unless $size && $size =~ /^(small|large)$/;
    return undef unless my $id = $self->id;
    return [ $id, "mogp.vg.$size.$id" ];
}

sub created_by_memkey {
    my ( $self, $uid ) = @_;
    $uid = $self->creatorid unless defined $uid;
    return [ $uid, "vgift.creatorid.$uid" ];
}

sub taglist_memkey {
    my ($self) = @_;
    return undef unless my $id = $self->id;
    return [ $id, "vgift.taglist.$id" ];    # list of tags for this giftid
}

sub tagged_with_memkey {
    my ( $self, $tagid ) = @_;
    return undef unless defined $tagid;
    return [ $tagid, "vgift.tagid.$tagid" ];    # list of gifts for this tagid
}

sub num_sold_memkey {
    my ($self) = @_;
    return undef unless my $id = $self->id;
    return [ $id, "vgift.count.$id" ];
}

sub untagged_memkey { return 'vgift_untagged'; }

sub tagcounts_approved_memkey { return 'vgift_tagcounts_approved'; }

sub tagcounts_active_memkey { return 'vgift_tagcounts_active'; }

sub creatorcounts_memkey { return 'vgift_creatorcounts'; }

sub nonpriv_tags_memkey { return 'vgift_nonpriv_tags'; }

# 4. Validation methods
sub validate_all {
    my ( $self, $err, $arg ) = @_;

    # err is optional scalar reference for error message.
    # arg is optional hashref; if missing, validate the object.
    my $data = $arg || $self;
    my $ok   = 1;
    $ok &&= $self->validate( $_ => $data->{$_}, $err ) foreach PROPLIST;
    return $ok;
}

sub validate {
    my ( $self, $key, $val, $err ) = @_;

    return $self->_valid_mime( $val, $err, $key ) if $key eq 'mime_small';
    return $self->_valid_mime( $val, $err, $key ) if $key eq 'mime_large';
    return $self->_valid_text( $val, $err, $key ) if $key eq 'description';
    return $self->_valid_text( $val, $err, $key ) if $key eq 'approved_why';
    return $self->_valid_name( $val, $err, $key ) if $key eq 'name';
    return $self->_valid_y_n( $val, $err, $key ) if $key eq 'active';
    return $self->_valid_y_n( $val, $err, $key ) if $key eq 'custom';
    return $self->_valid_y_n( $val, $err, $key ) if $key eq 'featured';
    return $self->_valid_y_n( $val, $err, $key ) if $key eq 'approved';
    return $self->_valid_uid( $val, $err, $key ) if $key eq 'approved_by';
    return $self->_valid_uid( $val, $err, $key ) if $key eq 'creatorid';
    return $self->_valid_int( $val, $err, $key ) if $key eq 'vgiftid';
    return $self->_valid_int( $val, $err, $key ) if $key eq 'created_t';
    return $self->_valid_int( $val, $err, $key ) if $key eq 'cost';

    # default case if no test defined for $key: assume invalid
    $$err = LJ::Lang::ml( 'vgift.error.validate.property', { key => $key } );
    return 0;
}

sub _valid_name {
    my ( $self, $name, $err ) = @_;

    return 1 unless $name;

    if ( $name !~ /\S/ || $name =~ /[\r\n\t\0]/ ) {
        $$err = LJ::Lang::ml('vgift.error.validate.name');
        return 0;
    }
    return $self->_valid_text( $name, $err, 'name' );
}

sub _valid_mime {
    my ( $self, $mime, $err, $prop ) = @_;

    if ( $mime && $mime !~ /^image\// ) {
        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
        return 0;
    }
    return 1;
}

sub _valid_int {
    my ( $self, $int, $err, $prop ) = @_;

    if ( $int && $int !~ /^\d+$/ ) {
        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
        return 0;
    }
    return 1;
}

sub _valid_y_n {
    my ( $self, $yn, $err, $prop ) = @_;

    if ( $yn && $yn !~ /^[YN]$/i ) {
        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
        return 0;
    }
    return 1;
}

sub _valid_uid {
    my ( $self, $uid, $err, $prop ) = @_;

    if ( defined $uid && !LJ::load_userid($uid) ) {
        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
        return 0;
    }

    # Not going to check user privileges at this level.
    # Also, uid 0 ought to be valid (indicates created by "the site").
    return 1;
}

sub _valid_text {
    my ( $self, $text, $err, $prop ) = @_;

    unless ( LJ::text_in($text) ) {
        $$err = LJ::Lang::ml( 'vgift.error.validate.text', { prop => $prop } );
        return 0;
    }
    return 1;
}

sub validate_priv {
    my ( $self, $priv ) = @_;
    return undef unless $priv;
    my $dbr = LJ::get_db_reader();
    if ( $priv =~ /^\d+$/ ) {

        # id->name
        return $dbr->selectrow_array( 'SELECT privcode FROM priv_list WHERE prlid = ?',
            undef, $priv );
    }
    else {
        # name->id
        return $dbr->selectrow_array( 'SELECT prlid FROM priv_list WHERE privcode = ?',
            undef, $priv );
    }
}

# 5. Aggregate methods
sub _findall {
    my ( $self, $sql ) = @_;
    return undef unless $sql;
    my $dbr = LJ::get_db_reader();
    my $ids = $dbr->selectcol_arrayref(
        "SELECT vgiftid FROM vgift_ids WHERE $sql ORDER BY created_t DESC");
    die $dbr->errstr if $dbr->err;
    return undef unless $ids;
    return map { $self->new($_) } @$ids;
}

sub _findall_cached {
    my ( $self, $memkey, $sql ) = @_;
    return undef unless $sql;
    return $self->_findall($sql) unless $memkey;

    my $data = LJ::MemCache::get($memkey);
    return map { $self->new($_) } @$data if $data && ref $data;

    # if it's not in memcache, run the query and update memcache
    my @vgs = $self->_findall($sql);
    my @ids = map { $_->id } @vgs;
    LJ::MemCache::set( $memkey, \@ids, 24 * 3600 );
    return @vgs;
}

sub list_inactive { $_[0]->_findall("active='N'") }

sub list_queued { $_[0]->_findall("approved IS NULL AND custom='N'") }

sub list_recent {
    my ( $self, $days ) = @_;
    return undef unless defined $days && $days =~ /^\d+$/;
    my $secs = time - $days * 24 * 3600;
    return $self->_findall("created_t > $secs AND custom='N'");
}

sub list_created_by {
    my ( $self, $u ) = @_;
    my $uid = LJ::want_userid($u);
    return undef unless $uid;
    my $memkey = $self->created_by_memkey($uid);
    return $self->_findall_cached( $memkey, "creatorid=$uid AND custom='N'" );
}

sub list_untagged {
    my ($self) = @_;
    return $self->_findall_cached( $self->untagged_memkey,
        "custom='N' AND vgiftid NOT IN (SELECT DISTINCT vgiftid FROM vgift_tags)" );
}

sub list_tagged_with {
    my ( $self, $tagname ) = @_;
    return undef if !$tagname || ref $tagname;

    my $tagid = $self->get_tagid($tagname);
    return undef unless $tagid;

    my $memkey = $self->tagged_with_memkey($tagid);
    my $vgs    = LJ::MemCache::get($memkey) || [];

    unless (@$vgs) {
        my $dbr = LJ::get_db_reader();
        $vgs = $dbr->selectcol_arrayref(
            "SELECT vgiftid FROM vgift_tags " . "WHERE tagid=$tagid " . "ORDER BY vgiftid DESC" );
        die $dbr->errstr if $dbr->err;
        LJ::MemCache::set( $memkey, $vgs, 600 );
    }

    return map { $self->new($_) } @$vgs;
}

sub _fetch_tagcounts {
    my ( $self, $memkey, $select ) = @_;
    my $counts = LJ::MemCache::get($memkey) || {};

    unless (%$counts) {
        my $dbr = LJ::get_db_reader();
        my $rows =
            $dbr->selectall_arrayref( "SELECT sk.keyword, COUNT(vt.vgiftid) "
                . "FROM sitekeywords AS sk, vgift_tags AS vt, vgift_ids AS vi "
                . "WHERE sk.kwid = vt.tagid AND vi.vgiftid = vt.vgiftid "
                . "AND vi.$select GROUP BY keyword ORDER BY keyword ASC" );
        die $dbr->errstr if $dbr->err;

        $counts = { map { $_->[0] => $_->[1] } @$rows };

        # also select from vgift_tagpriv in case we've defined
        # a privileged tag with no gifts available

        my $privempty =
            $dbr->selectcol_arrayref( "SELECT keyword FROM sitekeywords WHERE kwid IN "
                . "(SELECT DISTINCT tagid FROM vgift_tagpriv WHERE tagid NOT IN "
                . "(SELECT DISTINCT tagid FROM vgift_tags)) ORDER BY keyword ASC" );
        die $dbr->errstr if $dbr->err;

        $counts->{$_} = 0 foreach @$privempty;

        LJ::MemCache::set( $memkey, $counts, 24 * 3600 );
    }

    return $counts;
}

sub fetch_tagcounts_approved {
    my ($self) = @_;
    return $self->_fetch_tagcounts( $self->tagcounts_approved_memkey, "approved='Y'" );
}

sub fetch_tagcounts_active {
    my ($self) = @_;
    return $self->_fetch_tagcounts( $self->tagcounts_active_memkey, "active='Y'" );
}

sub fetch_creatorcounts {
    my ( $self, $type ) = @_;
    my $memkey = $self->creatorcounts_memkey;
    my $counts = LJ::MemCache::get($memkey) || {};

    unless (%$counts) {
        my $dbr = LJ::get_db_reader();
        my $ids =
            $dbr->selectcol_arrayref("SELECT DISTINCT creatorid FROM vgift_ids WHERE custom='N'");
        die $dbr->errstr if $dbr->err;

        foreach my $uid (@$ids) {
            $counts->{$uid}->{active}   = 0;
            $counts->{$uid}->{approved} = 0;

            foreach my $vg ( $self->list_created_by($uid) ) {
                $counts->{$uid}->{active}++   if $vg->is_active;
                $counts->{$uid}->{approved}++ if $vg->is_approved;
            }
        }

        LJ::MemCache::set( $memkey, $counts, 24 * 3600 );
    }

    return { map { $_ => $counts->{$_}->{$type} } keys %$counts }
        if $type && $type =~ /^(active|approved)$/;
    return $counts;
}

sub list_nonpriv_tags {
    my ($self) = @_;
    my $memkey = $self->nonpriv_tags_memkey;
    my $names  = LJ::MemCache::get($memkey) || [];

    unless (@$names) {
        my $dbr = LJ::get_db_reader();
        $names =
            $dbr->selectcol_arrayref( "SELECT keyword FROM sitekeywords WHERE kwid IN "
                . "(SELECT DISTINCT tagid FROM vgift_tags WHERE tagid NOT IN "
                . "(SELECT DISTINCT tagid FROM vgift_tagpriv)) ORDER BY keyword ASC" );
        die $dbr->errstr if $dbr->err;

        LJ::MemCache::set( $memkey, $names, 24 * 3600 );
    }
    return wantarray ? @$names : $names;
}

sub list_tagprivs {
    my ( $self, $tagname ) = @_;
    return undef if !$tagname || ref $tagname;

    my $tagid = $self->get_tagid($tagname);
    return undef unless $tagid;

    my $dbr = LJ::get_db_reader();
    my $rows =
        $dbr->selectall_arrayref( "SELECT pl.privcode, tp.arg FROM "
            . "priv_list AS pl, vgift_tagpriv AS tp "
            . "WHERE tp.tagid=$tagid AND "
            . "pl.prlid=tp.prlid "
            . "ORDER BY privcode ASC, arg ASC" );
    die $dbr->errstr if $dbr->err;

    return @$rows;
}

# 6. End-user display methods
sub display_basic {
    my $self = shift;
    my $id   = $self->id or return undef;
    my $ret  = '';

    $ret .= "<h2>" . $self->name_ehtml . " (#" . $self->id . ")</h2><p><b>";
    $ret .= LJ::Lang::ml(
        'vgift.display.createdby',
        {
            user => $self->creator->ljuser_display,
            ago  => $self->created_ago_text
        }
    );
    $ret .= "</b></p>\n" . $self->img_small_html;
    $ret .= "<p>" . $self->description_ehtml . "</p>\n";
    $ret .= "<p><b>" . LJ::Lang::ml('vgift.display.label.tags') . "</b> ";
    $ret .= $self->display_taglist . "</p>\n";

    return $ret;
}

sub display_summary {
    my $self = shift;
    my $id   = $self->id or return undef;
    my $ret  = '';

    $ret .= "<div style='clear: left'></div>\n";
    $ret .= "<div style='float: left; margin-right: 2em'>";
    $ret .= $self->img_small_html;
    $ret .= "</div><p style='margin-top: 0'><b>";
    $ret .= $self->name_ehtml . '</b>: <em>' . $self->description_ehtml;
    $ret .= "</em><br />";
    $ret .= LJ::Lang::ml(
        'vgift.display.createdby',
        {
            user => $self->creator->ljuser_display,
            ago  => $self->created_ago_text
        }
    );
    $ret .= "<br />" . LJ::Lang::ml('vgift.display.label.cost') . " ";
    $ret .= $self->display_cost . "</p>";

    return $ret;
}

sub display_taglist {

    # reverse of tags method: take in arrayref, return string
    my ( $self, $tags ) = @_;
    $tags = $self->tags unless $tags && ref $tags eq 'ARRAY';
    return LJ::ehtml( join( ', ', sort { $a cmp $b } @$tags ) );
}

sub display_cost {
    my ( $self, $cost ) = @_;
    $cost ||= $self->cost unless $self->is_free;
    return $cost
        ? LJ::Lang::ml( 'vgift.display.cost.points', { cost => $cost } )
        : LJ::Lang::ml('vgift.display.cost.free');
}

sub display_vieweditlinks {
    my ( $self, $review ) = @_;
    my $id       = $self->id or return '';
    my $linkroot = "$LJ::SITEROOT/admin/vgifts/";
    my %modes    = (
        view   => LJ::Lang::ml('vgift.display.linktext.viewedit'),
        review => LJ::Lang::ml('vgift.display.linktext.review'),
        delete => LJ::Lang::ml('vgift.display.linktext.delete'),
    );
    delete $modes{review} unless $review;
    delete $modes{delete} if $self->is_active;

    my $text = "";
    foreach my $mode (qw( view review delete )) {
        next unless $modes{$mode};
        $text .= ' | ' if $text;
        $text .= "<a href='$linkroot?mode=$mode&id=$id'>$modes{$mode}</a>";
    }
    return $text;
}

sub display_viewbylink {
    my ( $self, $uid ) = @_;
    my $u        = LJ::want_user($uid) or return '';
    my $user     = $u->user;
    my $linkroot = "$LJ::SITEROOT/admin/vgifts/";
    return
        " <a href='$linkroot?mode=view&user=$user'>"
        . LJ::Lang::ml('vgift.display.linktext.viewgifts') . "</a>";
}

sub display_creatorlist {
    my ( $self, $num ) = @_;
    my $data  = $self->fetch_creatorcounts;
    my $users = LJ::load_userids( keys %$data );
    my $sort  = sub {
        $data->{$b}->{active} <=> $data->{$a}->{active}
            || $data->{$b}->{approved} <=> $data->{$a}->{approved}
            || $users->{$a}->user cmp $users->{$b}->user;
    };
    my @creatorlist = map { [ $users->{$_}, $data->{$_}->{approved}, $data->{$_}->{active} ] }
        sort $sort keys %$data;
    my @printlist;

    foreach (@creatorlist) {
        last if $num && $num == scalar @printlist;
        my ( $u, $approved, $active ) = @$_;
        my $text = '<li>';
        $text .= LJ::Lang::ml(
            'vgift.display.creatorlist.counts',
            {
                user     => $u->ljuser_display,
                approved => $approved,
                active   => $active
            }
        );
        $text .= $self->display_viewbylink($u) . "</li>\n";
        push @printlist, $text;
    }
    return join '', @printlist;
}

# 7. Notification methods
sub notify_approved {
    my ( $self, $id ) = @_;

    if ($id) {    # transform class method -> object method
        $self = $self->new($id) or return;
    }
    else {        # verify object
        $id = $self->id or return;
    }

    # make sure the gift was actually reviewed
    return if $self->is_queued;

    # notify the user (inbox only, no opt-out)
    my @args = ( $self->creator, $self->approver, $self );
    LJ::Event::VgiftApproved->new(@args)->fire;
}

1;
