# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::Comment;

use 5.10.1;
use strict;

use base qw(Bugzilla::Object);

use Bugzilla::Attachment;
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::User;
use Bugzilla::Util;

use Scalar::Util qw(blessed);

###############################
####    Initialization     ####
###############################

# Creation and updating of comments are audited in longdescs
# and bugs_activity respectively instead of audit_log.
use constant AUDIT_CREATES => 0;
use constant AUDIT_UPDATES => 0;

use constant DB_COLUMNS => qw(
    comment_id
    bug_id
    who
    bug_when
    work_time
    thetext
    isprivate
    already_wrapped
    type
    extra_data
);

use constant UPDATE_COLUMNS => qw(
    isprivate
    type
    extra_data
);

use constant DB_TABLE => 'longdescs';
use constant ID_FIELD => 'comment_id';
# In some rare cases, two comments can have identical timestamps. If
# this happens, we want to be sure that the comment added later shows up
# later in the sequence.
use constant LIST_ORDER => 'bug_when, comment_id';

use constant VALIDATORS => {
    bug_id      => \&_check_bug_id,
    who         => \&_check_who,
    bug_when    => \&_check_bug_when,
    work_time   => \&_check_work_time,
    thetext     => \&_check_thetext,
    isprivate   => \&_check_isprivate,
    extra_data  => \&_check_extra_data,
    type        => \&_check_type,
};

use constant VALIDATOR_DEPENDENCIES => {
    extra_data => ['type'],
    bug_id     => ['who'],
    work_time  => ['who', 'bug_id'],
    isprivate  => ['who'],
};

#########################
# Database Manipulation #
#########################

sub update {
    my $self = shift;
    my $changes = $self->SUPER::update(@_);
    $self->bug->_sync_fulltext();
    return $changes;
}

# Speeds up displays of comment lists by loading all ->author objects
# at once for a whole list.
sub preload {
    my ($class, $comments) = @_;
    my %user_ids = map { $_->{who} => 1 } @$comments;
    my $users = Bugzilla::User->new_from_list([keys %user_ids]);
    my %user_map = map { $_->id => $_ } @$users;
    foreach my $comment (@$comments) {
        $comment->{author} = $user_map{$comment->{who}};
    }
}

###############################
####      Accessors      ######
###############################

sub already_wrapped { return $_[0]->{'already_wrapped'}; }
sub body        { return $_[0]->{'thetext'};   }
sub bug_id      { return $_[0]->{'bug_id'};    }
sub creation_ts { return $_[0]->{'bug_when'};  }
sub is_private  { return $_[0]->{'isprivate'}; }
sub work_time   {
    # Work time is returned as a string (see bug 607909)
    return 0 if $_[0]->{'work_time'} + 0 == 0;
    return $_[0]->{'work_time'};
}
sub type        { return $_[0]->{'type'};      }
sub extra_data  { return $_[0]->{'extra_data'} }

sub bug {
    my $self = shift;
    require Bugzilla::Bug;
    $self->{bug} ||= new Bugzilla::Bug($self->bug_id);
    return $self->{bug};
}

sub is_about_attachment {
    my ($self) = @_;
    return 1 if ($self->type == CMT_ATTACHMENT_CREATED
                 or $self->type == CMT_ATTACHMENT_UPDATED);
    return 0;
}

sub attachment {
    my ($self) = @_;
    return undef if not $self->is_about_attachment;
    $self->{attachment} ||= new Bugzilla::Attachment($self->extra_data);
    return $self->{attachment};
}

sub author { 
    my $self = shift;
    $self->{'author'} ||= new Bugzilla::User($self->{'who'});
    return $self->{'author'};
}

sub body_full {
    my ($self, $params) = @_;
    $params ||= {};
    my $template = Bugzilla->template_inner;
    my $body;
    if ($self->type) {
        $template->process("bug/format_comment.txt.tmpl", 
                           { comment => $self, %$params }, \$body)
            || ThrowTemplateError($template->error());
        $body =~ s/^X//;
    }
    else {
        $body = $self->body;
    }
    if ($params->{wrap} and !$self->already_wrapped) {
        $body = wrap_comment($body);
    }
    return $body;
}

############
# Mutators #
############

sub set_is_private  { $_[0]->set('isprivate',  $_[1]); }
sub set_type        { $_[0]->set('type',       $_[1]); }
sub set_extra_data  { $_[0]->set('extra_data', $_[1]); }

##############
# Validators #
##############

sub run_create_validators {
    my $self = shift;
    my $params = $self->SUPER::run_create_validators(@_);
    # Sometimes this run_create_validators is called with parameters that
    # skip bug_id validation, so it might not exist in the resulting hash.
    if (defined $params->{bug_id}) {
        $params->{bug_id} = $params->{bug_id}->id;
    }
    return $params;
}

sub _check_extra_data {
    my ($invocant, $extra_data, undef, $params) = @_;
    my $type = blessed($invocant) ? $invocant->type : $params->{type};

    if ($type == CMT_NORMAL) {
        if (defined $extra_data) {
            ThrowCodeError('comment_extra_data_not_allowed',
                           { type => $type, extra_data => $extra_data });
        }
    }
    else {
        if (!defined $extra_data) {
            ThrowCodeError('comment_extra_data_required', { type => $type });
        }
        elsif ($type == CMT_ATTACHMENT_CREATED 
               or $type == CMT_ATTACHMENT_UPDATED) 
        {
             my $attachment = Bugzilla::Attachment->check({ 
                 id => $extra_data });
             $extra_data = $attachment->id;
        }
        else {
            my $original = $extra_data;
            detaint_natural($extra_data) 
              or ThrowCodeError('comment_extra_data_not_numeric',
                                { type => $type, extra_data => $original });
        }
    }

    return $extra_data;
}

sub _check_type {
    my ($invocant, $type) = @_;
    $type ||= CMT_NORMAL;
    my $original = $type;
    detaint_natural($type)
        or ThrowCodeError('comment_type_invalid', { type => $original });
    return $type;
}

sub _check_bug_id {
    my ($invocant, $bug_id) = @_;

    ThrowCodeError('param_required', {function => 'Bugzilla::Comment->create',
                                      param => 'bug_id'}) unless $bug_id;

    my $bug;
    if (blessed $bug_id) {
        # We got a bug object passed in, use it
        $bug = $bug_id;
        $bug->check_is_visible;
    }
    else {
        # We got a bug id passed in, check it and get the bug object
        $bug = Bugzilla::Bug->check({ id => $bug_id });
    }

    # Make sure the user can edit the product
    Bugzilla->user->can_edit_product($bug->{product_id});

    # Make sure the user can comment
    my $privs;
    $bug->check_can_change_field('longdesc', 0, 1, \$privs)
        || ThrowUserError('illegal_change', 
                          { field => 'longdesc', privs => $privs });
    return $bug;
}

sub _check_who {
    my ($invocant, $who) = @_;
    Bugzilla->login(LOGIN_REQUIRED);
    return Bugzilla->user->id;
}

sub _check_bug_when {
    my ($invocant, $when) = @_;

    # Make sure the timestamp is defined, default to a timestamp from the db
    if (!defined $when) {
        $when = Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
    }

    # Make sure the timestamp parses
    if (!datetime_from($when)) {
        ThrowCodeError('invalid_timestamp', { timestamp => $when });
    }

    return $when;
}

sub _check_work_time {
    my ($invocant, $value_in, $field, $params) = @_;

    # Call down to Bugzilla::Object, letting it know negative
    # values are ok
    my $time = $invocant->check_time($value_in, $field, $params, 1);
    my $privs;
    $params->{bug_id}->check_can_change_field('work_time', 0, $time, \$privs)
        || ThrowUserError('illegal_change',
                          { field => 'work_time', privs => $privs });
    return $time;
}

sub _check_thetext {
    my ($invocant, $thetext) = @_;

    ThrowCodeError('param_required',{function => 'Bugzilla::Comment->create',
                                     param => 'thetext'}) unless defined $thetext;

    # Remove any trailing whitespace. Leading whitespace could be
    # a valid part of the comment.
    $thetext =~ s/\s*$//s;
    $thetext =~ s/\r\n?/\n/g; # Get rid of \r.

    ThrowUserError('comment_too_long') if length($thetext) > MAX_COMMENT_LENGTH;
    return $thetext;
}

sub _check_isprivate {
    my ($invocant, $isprivate) = @_;
    if ($isprivate && !Bugzilla->user->is_insider) {
        ThrowUserError('user_not_insider');
    }
    return $isprivate ? 1 : 0;
}

sub count {
    my ($self) = @_;

    return $self->{'count'} if defined $self->{'count'};

    my $dbh = Bugzilla->dbh;
    ($self->{'count'}) = $dbh->selectrow_array(
        "SELECT COUNT(*)
           FROM longdescs 
          WHERE bug_id = ? 
                AND bug_when <= ?",
        undef, $self->bug_id, $self->creation_ts);

    return --$self->{'count'};
}   

1;

__END__

=head1 NAME

Bugzilla::Comment - A Comment for a given bug 

=head1 SYNOPSIS

 use Bugzilla::Comment;

 my $comment = Bugzilla::Comment->new($comment_id);
 my $comments = Bugzilla::Comment->new_from_list($comment_ids);

=head1 DESCRIPTION

Bugzilla::Comment represents a comment attached to a bug.

This implements all standard C<Bugzilla::Object> methods. See 
L<Bugzilla::Object> for more details.

=head2 Accessors

=over

=item C<bug_id>

C<int> The ID of the bug to which the comment belongs.

=item C<creation_ts>

C<string> The comment creation timestamp.

=item C<body>

C<string> The body without any special additional text.

=item C<work_time>

C<string> Time spent as related to this comment.

=item C<is_private>

C<boolean> Comment is marked as private

=item C<already_wrapped>

If this comment is stored in the database word-wrapped, this will be C<1>.
C<0> otherwise.

=item C<author>

L<Bugzilla::User> who created the comment.

=item C<count>

C<int> The position this comment is located in the full list of comments for a bug starting from 0.

=item C<body_full>

=over

=item B<Description>

C<string> Body of the comment, including any special text (such as
"this bug was marked as a duplicate of...").

=item B<Params>

=over

=item C<is_bugmail>

C<boolean>. C<1> if this comment should be formatted specifically for
bugmail.

=item C<wrap>

C<boolean>. C<1> if the comment should be returned word-wrapped.

=back

=item B<Returns>

A string, the full text of the comment as it would be displayed to an end-user.

=back

=back

=cut
