# 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::Subscription;

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

use Carp qw/ croak confess /;

use LJ::Event;
use LJ::NotificationMethod;
use LJ::Subscription::Pending;
use LJ::Typemap;

use constant {
    INACTIVE => 1 << 0,    # user has deactivated
    DISABLED => 1 << 1,    # system has disabled
    TRACKING => 1 << 2,    # subs in the "notices" category
};

my @subs_fields = qw(userid subid is_dirty journalid etypeid arg1 arg2
    ntypeid createtime expiretime flags);

sub new_by_id {
    my ( $class, $u, $subid ) = @_;
    croak "new_by_id requires a valid 'u' object"
        unless LJ::isu($u);
    return if $u->is_expunged;

    croak "invalid subscription id passed"
        unless defined $subid && int($subid) > 0;

    my $row = $u->selectrow_hashref(
        "SELECT userid, subid, is_dirty, journalid, etypeid, "
            . "arg1, arg2, ntypeid, createtime, expiretime, flags "
            . "FROM subs WHERE userid=? AND subid=?",
        undef, $u->{userid}, $subid
    );
    die $u->errstr if $u->err;

    return $class->new_from_row($row);
}

sub freeze {
    my $self = shift;
    return "subid-" . $self->owner->{userid} . '-' . $self->id;
}

# can return either a LJ::Subscription or LJ::Subscription::Pending object
sub thaw {
    my ( $class, $data, $u, $POST ) = @_;

    # valid format?
    return undef unless ( $data =~ /^(pending|subid) - $u->{userid} .+ ?(-old)?$/x );

    my ( $type, $userid, $subid ) = split( "-", $data );

    return LJ::Subscription::Pending->thaw( $data, $u, $POST ) if $type eq 'pending';
    die "Invalid subscription data type: $type" unless $type eq 'subid';

    unless ($u) {
        my $subuser = LJ::load_userid($userid);
        die "no user" unless $subuser;
        $u = LJ::get_authas_user($subuser);
        die "Invalid user $subuser->{user}" unless $u;
    }

    return $class->new_by_id( $u, $subid );
}

sub pending          { 0 }
sub default_selected { $_[0]->active && $_[0]->enabled }

sub subscriptions_of_user {
    my ( $class, $u ) = @_;
    croak "subscriptions_of_user requires a valid 'u' object"
        unless LJ::isu($u);

    return if $u->is_expunged;
    return @{ $u->{_subscriptions} } if defined $u->{_subscriptions};

    my $sth =
        $u->prepare( "SELECT userid, subid, is_dirty, journalid, etypeid, "
            . "arg1, arg2, ntypeid, createtime, expiretime, flags "
            . "FROM subs WHERE userid=?" );
    $sth->execute( $u->{userid} );
    die $u->errstr if $u->err;

    my @subs;
    while ( my $row = $sth->fetchrow_hashref ) {
        push @subs, LJ::Subscription->new_from_row($row);
    }

    $u->{_subscriptions} = \@subs;

    return @subs;
}

# Class method
# Look for a subscription matching the parameters: journalu/journalid,
#   ntypeid/method, event/etypeid, arg1, arg2
# Returns a list of subscriptions for this user matching the parameters
sub find {
    my ( $class, $u, %params ) = @_;

    my ( $etypeid, $ntypeid, $arg1, $arg2, $flags );

    if ( my $evt = delete $params{event} ) {
        $etypeid = LJ::Event->event_to_etypeid($evt);
    }

    if ( my $nmeth = delete $params{method} ) {
        $ntypeid = LJ::NotificationMethod->method_to_ntypeid($nmeth);
    }

    $etypeid ||= delete $params{etypeid};
    $ntypeid ||= delete $params{ntypeid};

    $flags = delete $params{flags};

    my $journalid = delete $params{journalid};
    $journalid ||= LJ::want_userid( delete $params{journal} ) if defined $params{journal};

    $arg1 = delete $params{arg1};
    $arg2 = delete $params{arg2};

    my $require_active = delete $params{require_active} ? 1 : 0;

    croak "Invalid parameters passed to ${class}->find" if keys %params;

    return () if defined $arg1 && $arg1 =~ /\D/;
    return () if defined $arg2 && $arg2 =~ /\D/;

    my @subs = $u->subscriptions;

    @subs = grep { $_->active && $_->enabled } @subs if $require_active;

    # filter subs on each parameter
    @subs = grep { $_->journalid == $journalid } @subs if defined $journalid;
    @subs = grep { $_->ntypeid == $ntypeid } @subs     if $ntypeid;
    @subs = grep { $_->etypeid == $etypeid } @subs     if $etypeid;
    if ( defined $flags ) {

        # check DISABLED and TRACKING flags, but not INACTIVE flag.
        @subs = grep { ( $flags & DISABLED ) == $_->disabled } @subs;
        @subs = grep { ( $flags & TRACKING ) == $_->is_tracking_category } @subs;
    }
    @subs = grep { $_->arg1 == $arg1 } @subs if defined $arg1;
    @subs = grep { $_->arg2 == $arg2 } @subs if defined $arg2;

    return @subs;
}

# Instance method
# Deactivates a subscription. If this is not a "tracking" subscription,
# it will delete it instead. Does nothing to disabled subscriptions.
sub deactivate {
    my $self = shift;

    my %opts  = @_;
    my $force = delete $opts{force};    # force-delete

    croak "Invalid args" if scalar keys %opts;

    my $subid = $self->id
        or croak "Invalid subsciption";

    my $u = $self->owner;

    # don't care about disabled subscriptions
    return if $self->disabled;

    # if it's the inbox method, deactivate/delete the other notification methods too
    my @to_remove = ();

    my @subs = $self->corresponding_subs;

    foreach my $subscr (@subs) {

        # Don't deactivate if the Inbox is always subscribed to
        my $always_checked = $subscr->event_class->always_checked ? 1 : 0;
        if ( $subscr->is_tracking_category && !$force ) {

            # delete non-inbox methods if we're deactivating
            if ( $subscr->method eq 'LJ::NotificationMethod::Inbox' && !$always_checked ) {
                $subscr->_deactivate;
            }
            else {
                $subscr->delete;
            }
        }
        else {
            $subscr->delete;
        }
    }
}

# deletes a subscription
sub delete {
    my $self = shift;
    my $u    = $self->owner;

    my @subs = $self->corresponding_subs;
    foreach my $subscr (@subs) {
        $u->do( "DELETE FROM subs WHERE subid=? AND userid=?", undef, $subscr->id, $u->id );
    }

    # delete from cache in user
    undef $u->{_subscriptions};

    return 1;
}

# class method, nukes all subs for a user
sub delete_all_subs {
    my ( $class, $u ) = @_;

    return if $u->is_expunged;
    $u->do( "DELETE FROM subs WHERE userid = ?", undef, $u->id );
    undef $u->{_subscriptions};

    return 1;
}

# class method, nukes all inactive subs for a user
sub delete_all_inactive_subs {
    my ( $class, $u, $dryrun ) = @_;

    return if $u->is_expunged;

    my @subs = $class->find($u);
    @subs = grep { !( $_->active && $_->enabled ) } @subs;
    my $count = scalar @subs;
    if ( $count > 0 && !$dryrun ) {
        $_->delete foreach (@subs);
        undef $u->{_subscriptions};
    }

    return $count;
}

# find matching subscriptions with different notification methods
sub corresponding_subs {
    my $self = shift;

    my @subs = ($self);

    if ( $self->method eq 'LJ::NotificationMethod::Inbox' ) {
        push @subs,
            $self->owner->find_subscriptions(
            journalid => $self->journalid,
            etypeid   => $self->etypeid,
            arg1      => $self->arg1,
            arg2      => $self->arg2,
            );
    }

    return @subs;
}

# Class method
sub new_from_row {
    my ( $class, $row ) = @_;

    return undef unless $row;
    my $self = bless {%$row}, $class;

    # TODO validate keys of row.
    return $self;
}

sub create {
    my ( $class, $u, %args ) = @_;

    # easier way for eveenttype
    if ( my $evt = delete $args{'event'} ) {
        $args{etypeid} = LJ::Event->event_to_etypeid($evt);
    }

    # easier way to specify ntypeid
    if ( my $ntype = delete $args{'method'} ) {
        $args{ntypeid} = LJ::NotificationMethod->method_to_ntypeid($ntype);
    }

    # easier way to specify journal
    if ( my $ju = delete $args{'journal'} ) {
        $args{journalid} = $ju->{userid} if $ju;
    }

    $args{arg1} ||= 0;
    $args{arg2} ||= 0;

    $args{journalid} ||= 0;

    foreach (qw(ntypeid etypeid)) {
        croak "Required field '$_' not found in call to $class->create" unless defined $args{$_};
    }
    foreach (qw(userid subid createtime)) {
        croak "Can't specify field '$_'" if defined $args{$_};
    }

    # load current subscription, check if subscription already exists
    $class->subscriptions_of_user($u) unless $u->{_subscriptions};
    my ($existing) = grep {
               $args{etypeid} == $_->{etypeid}
            && $args{ntypeid} == $_->{ntypeid}
            && $args{journalid} == $_->{journalid}
            && $args{arg1} == $_->{arg1}
            && $args{arg2} == $_->{arg2}
            && ( $args{flags} & DISABLED ) == $_->disabled
            && ( $args{flags} & TRACKING ) == $_->is_tracking_category
    } @{ $u->{_subscriptions} };

    # allow matches if the activation state is unequal

    if ( defined $existing ) {
        $existing->activate;
        return $existing;
    }

    my $subid = LJ::alloc_user_counter( $u, 'E' )
        or die "Could not alloc subid for user $u->{user}";

    $args{subid}      = $subid;
    $args{userid}     = $u->{userid};
    $args{createtime} = time();

    my $self = $class->new_from_row( \%args );

    my @columns;
    my @values;

    foreach (@subs_fields) {
        if ( exists( $args{$_} ) ) {
            push @columns, $_;
            push @values,  delete $args{$_};
        }
    }

    croak( "Extra args defined, (" . join( ', ', keys(%args) ) . ")" ) if keys %args;

    my $sth =
        $u->prepare( 'INSERT INTO subs ('
            . join( ',', @columns ) . ')'
            . 'VALUES ('
            . join( ',', map { '?' } @values )
            . ')' );
    LJ::errobj($u)->throw if $u->err;

    $sth->execute(@values);
    die $sth->errstr if $sth->err;

    $self->subscriptions_of_user($u) unless $u->{_subscriptions};
    push @{ $u->{_subscriptions} }, $self;

    return $self;
}

# returns a hash of arguments representing this subscription (useful for passing to
# other functions, such as find)
sub sub_info {
    my $self = shift;
    return (
        journalid => $self->journalid,
        etypeid   => $self->etypeid,
        ntypeid   => $self->ntypeid,
        arg1      => $self->arg1,
        arg2      => $self->arg2,
        flags     => $self->flags,
    );
}

# returns a nice HTML description of this current subscription
sub as_html {
    my $self = shift;

    my $evtclass = LJ::Event->class( $self->etypeid );
    return undef unless $evtclass;
    return $evtclass->subscription_as_html($self);
}

sub set_tracking {
    my $self = shift;
    $self->set_flag(TRACKING);
}

sub activate {
    my $self = shift;
    $self->clear_flag(INACTIVE);
}

sub _deactivate {
    my $self = shift;
    $self->set_flag(INACTIVE);
}

sub enable {
    my $self = shift;

    $_->clear_flag(DISABLED) foreach $self->corresponding_subs;
}

sub disable {
    my $self = shift;

    $_->set_flag(DISABLED) foreach $self->corresponding_subs;
}

sub set_flag {
    my ( $self, $flag ) = @_;

    my $flags = $self->flags;

    # don't bother if flag already set
    return if $flags & $flag;

    $flags |= $flag;

    if ( $self->owner && !$self->pending ) {
        $self->owner->do( "UPDATE subs SET flags = flags | ? WHERE userid=? AND subid=?",
            undef, $flag, $self->owner->userid, $self->id );
        die $self->owner->errstr if $self->owner->errstr;

        $self->{flags} = $flags;
        delete $self->owner->{_subscriptions};
    }
}

sub clear_flag {
    my ( $self, $flag ) = @_;

    my $flags = $self->flags;

    # don't bother if flag already cleared
    return unless $flags & $flag;

    # clear the flag
    $flags &= ~$flag;

    if ( $self->owner && !$self->pending ) {
        $self->owner->do( "UPDATE subs SET flags = flags & ~? WHERE userid=? AND subid=?",
            undef, $flag, $self->owner->userid, $self->id );
        die $self->owner->errstr if $self->owner->errstr;

        $self->{flags} = $flags;
        delete $self->owner->{_subscriptions};
    }
}

sub id {
    my $self = shift;

    return $self->{subid};
}

sub createtime {
    my $self = shift;
    return $self->{createtime};
}

sub flags {
    my $self = shift;
    return $self->{flags} || 0;
}

sub active {
    my $self = shift;
    return !( $self->flags & INACTIVE );
}

sub enabled {
    my $self = shift;
    return !( $self->flags & DISABLED );
}

sub disabled {
    my $self = shift;
    return !$self->enabled;
}

sub is_tracking_category {
    my $self = shift;
    return $self->flags & TRACKING;
}

sub expiretime {
    my $self = shift;
    return $self->{expiretime};
}

sub journalid {
    my $self = shift;
    return $self->{journalid};
}

sub journal {
    my $self = shift;
    return LJ::load_userid( $self->{journalid} );
}

sub arg1 {
    my $self = shift;
    return $self->{arg1};
}

sub arg2 {
    my $self = shift;
    return $self->{arg2};
}

sub ntypeid {
    my $self = shift;
    return $self->{ntypeid};
}

sub method {
    my $self = shift;
    return LJ::NotificationMethod->class( $self->ntypeid );
}

sub notify_class {
    my $self = shift;
    return LJ::NotificationMethod->class( $self->{ntypeid} );
}

sub etypeid {
    my $self = shift;
    return $self->{etypeid};
}

sub event_class {
    my $self = shift;
    return LJ::Event->class( $self->{etypeid} );
}

# returns the owner (userid) of the subscription
sub userid {
    my $self = shift;
    return $self->{userid};
}

sub owner {
    my $self = shift;
    return LJ::load_userid( $self->{userid} );
}

sub dirty {
    my $self = shift;
    return $self->{is_dirty};
}

sub notification {
    my $subscr = shift;
    my $class  = LJ::NotificationMethod->class( $subscr->{ntypeid} );

    my $note = $class->new_from_subscription($subscr);

    return $note;
}

sub process {
    my ( $self, @events ) = @_;
    my $note = $self->notification or return;

    return 1
        if $self->etypeid == LJ::Event::OfficialPost->etypeid
        && !LJ::is_enabled('officialpost_esn');

  # significant events (such as SecurityAttributeChanged) must be processed even for inactive users.
    return 1
        unless $self->notify_class->configured_for_user( $self->owner )
        || LJ::Event->class( $self->etypeid )->is_significant;

    return $note->notify(@events);
}

sub unique {
    my $self = shift;

    my $note = $self->notification or return undef;
    return $note->unique . ':' . $self->owner->{user};
}

# returns true if two subscriptions are equivalent
sub equals {
    my ( $self, $other ) = @_;

    return 1 if defined $other->id && $self->id == $other->id;

    my $match =
           $self->ntypeid == $other->ntypeid
        && $self->etypeid == $other->etypeid
        && $self->flags == $other->flags;

    $match &&= $other->arg1 && ( $self->arg1 == $other->arg1 ) if $self->arg1;
    $match &&= $other->arg2 && ( $self->arg2 == $other->arg2 ) if $self->arg2;

    $match &&= $self->journalid == $other->journalid;

    return $match;
}

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

    $u ||= $self->owner;

    return $self->event_class->available_for_user( $u, $self );
}

package LJ::Error::Subscription::TooMany;
sub fields { qw(subscr u); }

sub as_html { $_[0]->as_string }

sub as_string {
    my $self = shift;
    my $max  = $self->field('u')->count_max_subscriptions;
    return
          'The notification tracking "'
        . $self->field('subscr')->as_html
        . '" was not saved because you have'
        . " reached your limit of $max active notifications. Notifications need to be deactivated before more can be added.";
}

# Too many subscriptions exist, not necessarily active
package LJ::Error::Subscription::TooManySystemMax;
sub fields { qw(subscr u max); }

sub as_html { $_[0]->as_string }

sub as_string {
    my $self = shift;
    my $max  = $self->field('max');
    return
          'The notification tracking "'
        . $self->field('subscr')->as_html
        . '" was not saved because you have'
        . " more than $max existing notifications. Notifications need to be completely removed before more can be added.";
}

1;
