#!/usr/bin/perl
#
# DW::Mood - Provide mood theme support. Replaces ljmood.pl from LJ.
#
# Authors:
#      Jen Griffin <kareila@livejournal.com>
#
# Copyright (c) 2010 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::Mood;

use strict;
use warnings;

use LJ::CleanHTML;

### MOOD (CLASS) METHODS

# load list of moods from DB (adapted from LJ::load_moods)
# arguments: none
# returns: true
sub load_moods {
    return 1 if $LJ::CACHED_MOODS;
    my $dbr  = LJ::get_db_reader();
    my $data = $dbr->selectall_arrayref("SELECT moodid, mood, parentmood, weight FROM moods");
    die $dbr->errstr if $dbr->err;

    $LJ::CACHED_MOOD_MAX ||= 0;

    foreach my $row (@$data) {
        my ( $id, $mood, $parent, $weight ) = @$row;
        $LJ::CACHE_MOODS{$id} = { name => $mood, parent => $parent, id => $id, weight => $weight };
        $LJ::CACHED_MOOD_MAX = $id if $id > $LJ::CACHED_MOOD_MAX;
    }
    return $LJ::CACHED_MOODS = 1;
}

# get list of moods from cache (adapted from LJ::get_moods)
# arguments: class
# returns: hashref
sub get_moods {
    my ($self) = @_;
    $self->load_moods;
    return \%LJ::CACHE_MOODS;
}

# mood name to id (or undef) (adapted from LJ::mood_id)
sub mood_id {
    my ( $self, $mood ) = @_;
    return undef unless $mood;

    $self->load_moods;
    foreach my $m ( values %LJ::CACHE_MOODS ) {
        return $m->{id} if $mood eq $m->{name};
    }
    return undef;
}

# mood id to name (or undef) (adapted from LJ::mood_name)
sub mood_name {
    my ( $self, $moodid ) = @_;
    return undef unless $moodid;

    $self->load_moods;
    my $m = $LJ::CACHE_MOODS{$moodid};
    return $m ? $m->{name} : undef;
}

# associate local moods with moodids on other sites
# arguments: local moodid or mood; external siteid
# returns: id of mood on the remote site, or 0 on failure
sub get_external_moodid {
    my ( $self, %opts ) = @_;

    my $siteid = $opts{siteid};
    my $moodid = $opts{moodid};
    my $mood   = $opts{mood};

    return 0 unless $siteid;
    return 0 unless $moodid || $mood;

    my $mood_text = $mood ? $mood : $self->mood_name($moodid);

    # determine which moodid on the external site
    # corresponds to the given $mood_text
    my $dbr = LJ::get_db_reader();
    my ($external_moodid) = $dbr->selectrow_array(
        "SELECT moodid FROM external_site_moods WHERE siteid = ?" . " AND LOWER( mood ) = ?",
        undef, $siteid, lc $mood_text );

    return $external_moodid ? $external_moodid : 0;
}

### THEME (OBJECT/CLASS) METHODS

# basic object construction: requires theme id
# arguments: theme id (required)
# returns: object reference, undef on failure
sub new {
    my ( $class, $id ) = @_;
    return undef unless $id;
    my $self = {};    # id set via object method below
    bless $self, ( ref $class ? ref $class : $class );
    return undef unless $self->id($id);
    $self->load_moods;    # not necessary but saves effort later
    return $self->load_theme;
}

# basic get/set for theme id
# arguments: set if theme id given, get if no args
# returns: value of theme id, undef on failure
sub id {
    my ( $self, $id ) = @_;
    return undef if $id && $id !~ /^\d+$/;    # invalid id
    return $id unless ref $self;              # class method
    return $self->{id} unless $id;            # get only
    return $self->{id} = $id;                 # set and return
}

# load theme data from DB (adapted from LJ::load_mood_theme)
# arguments: theme id (only required if called as class method)
# returns: object reference, undef on failure
sub load_theme {
    my ( $self, $themeid ) = @_;

    # force object path if called as class
    return $self->new($themeid) unless ref $self;

    # check themeid and assign it to the object
    return undef unless $themeid = $self->id($themeid);

    # check global memory cache
    return $self if $LJ::CACHE_MOOD_THEME{$themeid};

    # check memcache
    my $memkey = [ $themeid, "moodthemedata:$themeid" ];
    $LJ::CACHE_MOOD_THEME{$themeid} = LJ::MemCache::get($memkey);
    return $self if %{ $LJ::CACHE_MOOD_THEME{$themeid} || {} };

    # fall back to db
    $LJ::CACHE_MOOD_THEME{$themeid} = {};
    my $dbr = LJ::get_db_reader();

    # load picture rows from moodthemedata
    my $data = $dbr->selectall_arrayref(
        "SELECT moodid, picurl, width, height FROM moodthemedata " . "WHERE moodthemeid=?",
        undef, $themeid );
    die $dbr->errstr if $dbr->err;

    # load metadata from moodthemes
    my ( $name, $des, $is_public, $ownerid ) = $dbr->selectrow_array(
        "SELECT name, des, is_public, ownerid FROM moodthemes" . " WHERE moodthemeid=?",
        undef, $themeid );
    die $dbr->errstr if $dbr->err;

    LJ::MemCache::set( $memkey, {}, 3600 ) and return undef
        unless $name;    # no results for this theme

    $LJ::CACHE_MOOD_THEME{$themeid}->{moodthemeid} = $themeid;
    $LJ::CACHE_MOOD_THEME{$themeid}->{is_public}   = $is_public;
    $LJ::CACHE_MOOD_THEME{$themeid}->{ownerid}     = $ownerid;
    $LJ::CACHE_MOOD_THEME{$themeid}->{name}        = $name;
    $LJ::CACHE_MOOD_THEME{$themeid}->{des}         = $des;

    foreach my $d (@$data) {
        my ( $id, $pic, $w, $h ) = @$d;
        $LJ::CACHE_MOOD_THEME{$themeid}->{$id} =
            { pic => $pic, w => $w, h => $h };
    }

    # set in memcache
    LJ::MemCache::set( $memkey, $LJ::CACHE_MOOD_THEME{$themeid}, 3600 )
        if %{ $LJ::CACHE_MOOD_THEME{$themeid} || {} };

    return $self;
}

# object method to load a mood icon (adapted from LJ::get_mood_picture)
# arguments: moodid; hashref to assign with mood icon data
# returns: 1 on success, 0 otherwise.
sub get_picture {
    my ( $self, $moodid, $ref ) = @_;
    return 0 unless $ref && ref $ref;
    my $themeid = $self->id or return 0;

    while ($moodid) {

        # inheritance check
        unless ( $LJ::CACHE_MOOD_THEME{$themeid}
            && $LJ::CACHE_MOOD_THEME{$themeid}->{$moodid} )
        {
            $moodid = defined $LJ::CACHE_MOODS{$moodid} ? $LJ::CACHE_MOODS{$moodid}->{parent} : 0;
            next;
        }

        # load the data
        %{$ref} = %{ $LJ::CACHE_MOOD_THEME{$themeid}->{$moodid} };
        $ref->{moodid} = $moodid;

        # sanitize the value of pic
        if ( $ref->{pic} =~ m!^/! ) {
            $ref->{pic} =~ s!^/img!!;
            $ref->{pic} = $LJ::IMGPREFIX . $ref->{pic};
        }

        # must be a good url
        $ref->{pic} = "#invalid"
            unless $ref->{pic} =~ m!^https?://[^\'\"\0\s]+$!;
        $ref->{pic} = LJ::CleanHTML::https_url( $ref->{pic} );
        return 1;
    }
    return 0;    # couldn't find a picture anywhere in the parent chain
}

# object method to update or delete a mood icon
# arguments: moodid; hashref containing new mood icon data; error ref
# returns: 1 on success, undef otherwise.
sub set_picture {
    my ( $self, $moodid, $pic, $err, $dbh ) = @_;
    my $errsub = sub { $$err = $_[0] if ref $err; return undef };
    return $errsub->( LJ::Lang::ml("/manage/moodthemes.bml.error.cantupdatetheme") )
        unless $self->id
        and $moodid
        and ref $pic
        and $moodid =~ /^\d+$/;

    my ( $picurl, $w, $h ) = @{$pic}{qw/ picurl width height /};
    return $errsub->(
        LJ::Lang::ml(
            "/manage/moodthemes.bml.error.notanumber",
            { moodname => $self->mood_name($moodid) }
        )
        )
        if ( $w and $w !~ /^\d+$/ )
        or ( $h and $h !~ /^\d+$/ );
    return $errsub->( LJ::Lang::ml("/manage/moodthemes.bml.error.picurltoolong") )
        if $picurl and length $picurl > 200;

    $dbh ||= LJ::get_db_writer()
        or return $errsub->( LJ::Lang::ml("error.nodb") );

    if ( $picurl && $w && $h ) {    # do update
        $dbh->do(
            "REPLACE INTO moodthemedata (moodthemeid, moodid,"
                . " picurl, width, height) VALUES (?, ?, ?, ?, ?)",
            undef, $self->id, $moodid, $picurl, $w, $h
        );
    }
    else {                          # do delete
        $dbh->do( "DELETE FROM moodthemedata WHERE moodthemeid = ?" . " AND moodid= ?",
            undef, $self->id, $moodid );
    }
    return $errsub->( LJ::Lang::ml("error.dberror") . $dbh->errstr )
        if $dbh->err;
    $self->clear_cache;

    return 1;
}

# object method to update or delete multiple mood icons in one transaction
# arguments: arrayref of data arrayrefs [ $moodid => \%pic ]; error ref
# returns: 1 on success, undef otherwise.
sub set_picture_multi {
    my ( $self, $data, $err ) = @_;
    die "Need array reference for set_picture_multi"
        unless ref $data eq "ARRAY";

    my $errsub = sub { $$err = $_[0] if ref $err; return undef };
    my $dbh    = LJ::get_db_writer()
        or return $errsub->( LJ::Lang::ml("error.nodb") );
    $dbh->begin_work;
    return $errsub->( LJ::Lang::ml("error.dberror") . $dbh->errstr )
        if $dbh->err;

    foreach (@$data) {

        # we pass the database handle for transaction continuity
        my $rv = $self->set_picture( $_->[0], $_->[1], $err, $dbh );
        unless ($rv) {    # abort transaction
            $dbh->rollback;
            return undef;    # error message already in $err
        }
    }

    $dbh->commit;
    return $errsub->( LJ::Lang::ml("error.dberror") . $dbh->errstr )
        if $dbh->err;

    return 1;
}

# get theme description (adapted from LJ::mood_theme_des)
# arguments: theme id (only required if called as class method)
sub des {
    my $self = shift;
    return $self->prop( 'des', @_ );
}

# get named property of mood theme from cache
sub prop {
    my ( $self, $prop, $themeid ) = @_;

    if ( defined $themeid ) {

        # make sure the theme is valid and cached
        $self = $self->load_theme($themeid) or return;
    }
    else {
        # make sure we have an object loaded
        $themeid = $self->id or return;
    }

    my $m = $LJ::CACHE_MOOD_THEME{$themeid};
    return $m ? $m->{$prop} : undef;
}

# given a theme, lookup the user who owns it
# arguments: theme id (only required if called as class method)
# returns: userid, undef on failure
sub ownerid {
    my $self = shift;
    return $self->prop( 'ownerid', @_ );
}

# given a theme, check whether it is public
# arguments: theme id (only required if called as class method)
# returns: Y/N/undef
sub is_public {
    my $self = shift;
    return $self->prop( 'is_public', @_ );
}

# set named property of mood theme and clear cache
sub update {
    my ( $self, $prop, $newval, $themeid ) = @_;

    if ( defined $themeid ) {

        # make sure the theme is valid and cached
        $self = $self->load_theme($themeid) or return;
    }
    else {
        # make sure we have an object loaded
        $themeid = $self->id or return;
    }

    # validity check
    my $m = $LJ::CACHE_MOOD_THEME{$themeid};
    return unless $m && $m->{$prop};
    my $ownerid = $m->{ownerid};

    # do the update
    my $dbh = LJ::get_db_writer() or return;
    $dbh->do( "UPDATE moodthemes SET $prop = ? WHERE moodthemeid = ?", undef, $newval, $themeid );
    die $dbh->errstr if $dbh->err;

    $self->clear_cache;
    LJ::MemCache::delete("moods_public") if $prop eq 'is_public';

    # the following are equivalent to $u->delete_moodtheme_cache
    LJ::MemCache::delete( [ $ownerid, "moodthemes:$ownerid" ] );
    LJ::MemCache::delete( [ $newval,  "moodthemes:$newval" ] )
        if $prop eq 'ownerid';

    return 1;
}

# clear cached theme data from memory
# arguments: theme id (only required if called as class method)
# returns: nothing
sub clear_cache {
    my ( $self, $themeid ) = @_;

    # load theme id from object if needed
    $themeid ||= $self->id if ref $self;

    # clear the caches
    LJ::MemCache::delete( [ $themeid, "moodthemedata:$themeid" ] );
    delete $LJ::CACHE_MOOD_THEME{$themeid};
}

# get list of theme data for given theme and/or user
# arguments: hashref { themeid => ?, ownerid => ? }
# returns: array of hashrefs from memcache or db, undef on failure
sub get_themes {
    my ( $self, $arg ) = @_;

    # if called with no arguments, check for object id
    $arg ||= { themeid => $self->id } if ref $self;
    return undef unless $arg;

    my ( $themeid, $ownerid ) = ( $arg->{themeid}, $arg->{ownerid} );
    $ownerid ||= $self->ownerid($themeid);
    return undef unless $ownerid;

    # cache contains list of all themes for this user
    my $memkey = [ $ownerid, "moodthemes:$ownerid" ];
    my $ids    = LJ::MemCache::get($memkey);
    unless ( defined $ids ) {

        # check database
        my $dbr = LJ::get_db_reader() or return undef;
        $ids = $dbr->selectcol_arrayref(
            "SELECT moodthemeid FROM moodthemes" . " WHERE ownerid=? ORDER BY name",
            undef, $ownerid );
        die $dbr->errstr if $dbr->err;

        # update memcache
        LJ::MemCache::set( $memkey, $ids, 3600 );
    }

    # if they specified a theme, see if it's in the list
    if ( $themeid and grep { $_ == $themeid } @$ids ) {
        $self->load_theme($themeid);
        my $data = $LJ::CACHE_MOOD_THEME{$themeid};
        return wantarray ? ($data) : $data;
    }
    elsif ($themeid) {

        # not in the list: ownerid doesn't own themeid
        return undef;
    }

    # if they didn't specify a theme, return everything
    return $self->_load_data_multiple($ids);
}

sub _load_data_multiple {
    my ( $self, $themes ) = @_;
    my @data;
    foreach (@$themes) {
        $self->load_theme($_);
        push @data, $LJ::CACHE_MOOD_THEME{$_};
    }
    return @data;
}

# class method to get data for all public themes
# arguments: class
# returns: array of hashrefs from memcache or db, undef on failure
sub public_themes {
    my ($self) = @_;
    my $memkey = "moods_public";

    # only ids are in memcache
    my $ids = LJ::MemCache::get($memkey);
    unless ( defined $ids ) {

        # check database
        my $dbr = LJ::get_db_reader() or return undef;
        $ids = $dbr->selectcol_arrayref(
            "SELECT moodthemeid FROM moodthemes" . " WHERE is_public='Y' ORDER BY name" );
        die $dbr->errstr if $dbr->err;

        # update memcache
        LJ::MemCache::set( $memkey, $ids, 3600 );
    }
    return $self->_load_data_multiple($ids);
}

# END package DW::Mood;

package LJ::User;

# user method for accessing the currently selected moodtheme
sub moodtheme { return $_[0]->{moodthemeid}; }

# user method for expiring moodtheme cache
# NOTE: any code that updates the moodthemes table needs to use this!
sub delete_moodtheme_cache { $_[0]->memc_delete('moodthemes'); }

# user method for deleting existing mood theme
sub delete_moodtheme {
    my ( $u, $id ) = @_;
    my $dbh = LJ::get_db_writer() or return;
    my $rv  = $dbh->do( "DELETE FROM moodthemes WHERE moodthemeid = ?" . " AND ownerid = ?",
        undef, $id, $u->userid );
    die $dbh->errstr if $dbh->err;
    return unless $rv;    # will return if $u doesn't own this theme
    $dbh->do( "DELETE FROM moodthemedata WHERE moodthemeid = ?", undef, $id );
    die $dbh->errstr if $dbh->err;

    # Kill any memcache data about this moodtheme
    DW::Mood->clear_cache($id);
    $u->delete_moodtheme_cache;

    return 1;
}

# user method for creating new mood theme
# args: theme name, description, errorref
# returns: id of new theme or undef on failure
sub create_moodtheme {
    my ( $u, $name, $desc, $err ) = @_;
    my $errsub = sub { $$err = $_[0] if ref $err; return undef };

    return $errsub->( LJ::Lang::ml("/manage/moodthemes.bml.error.cantcreatethemes") )
        unless $u->can_create_moodthemes;
    return $errsub->( LJ::Lang::ml("/manage/moodthemes.bml.error.nonamegiven") )
        unless $name;
    $desc ||= '';

    my $dbh = LJ::get_db_writer()
        or return $errsub->( LJ::Lang::ml("error.nodb") );
    my $sth = $dbh->prepare(
        "INSERT INTO moodthemes " . "(ownerid, name, des, is_public) VALUES (?, ?, ?, 'N')" );
    $sth->execute( $u->id, $name, $desc )
        or return $errsub->( LJ::Lang::ml("error.dberror") . $dbh->errstr );

    $u->delete_moodtheme_cache;
    return $dbh->{mysql_insertid};
}

1;
