# 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::Extension::BugModal::ActivityStream;
1;

package Bugzilla::Bug;

use 5.10.1;
use strict;
use warnings;

use Bugzilla::Extension::BugModal::Util qw(date_str_to_time);
use Bugzilla::User;
use Bugzilla::Constants;
use List::MoreUtils qw(any);

# returns an arrayref containing all changes to the bug - comments, field
# changes, and duplicates
# [
#   {
#       time    => $unix_timestamp,
#       user_id => actor user-id
#       comment => optional, comment added
#       id      => unique identifier for this change-set
#       cc_only => boolean
#       activty => [
#           {
#               who     => user object
#               when    => time (string)
#               cc_only => boolean
#               changes => [
#                   {
#                       fieldname     => field name :)
#                       added         => string
#                       removed       => string
#                       flagtype_name => string (optional, name of flag if fieldname is 'flagtypes.name')
#                   }
#                   ...
#               ]
#           }
#           ...
#       ]
#   },
#   ...
# ]

sub activity_stream {
  my ($self) = @_;
  if (!$self->{activity_stream}) {
    my $stream = [];
    _add_comments_to_stream($self, $stream);
    _add_activities_to_stream($self, $stream);
    _add_duplicates_to_stream($self, $stream);

    my $base_time = date_str_to_time($self->creation_ts);
    foreach my $change_set (@$stream) {
      $change_set->{id}
        = $change_set->{comment}
        ? 'c' . $change_set->{comment}->count
        : 'a' . ($change_set->{time} - $base_time) . '_' . $change_set->{user_id};
      foreach my $activity (@{$change_set->{activity}}) {
        $activity->{changes}
          = [sort { $a->{fieldname} cmp $b->{fieldname} } @{$activity->{changes}}];
      }
    }
    my $order = Bugzilla->user->setting('comment_sort_order');
    if ($order eq 'oldest_to_newest') {
      $self->{activity_stream} = [sort { $a->{time} <=> $b->{time} } @$stream];
    }
    elsif ($order eq 'newest_to_oldest') {
      $self->{activity_stream} = [sort { $b->{time} <=> $a->{time} } @$stream];
    }
    elsif ($order eq 'newest_to_oldest_desc_first') {
      my $desc = shift @$stream;
      $self->{activity_stream} = [$desc, sort { $b->{time} <=> $a->{time} } @$stream];
    }
  }
  return $self->{activity_stream};
}

sub find_activity_id_for_attachment {
  my ($self, $attachment) = @_;
  my $attach_id = $attachment->id;
  my $stream    = $self->activity_stream;
  foreach my $change_set (@$stream) {
    next unless exists $change_set->{attach_id};
    return $change_set->{id} if $change_set->{attach_id} == $attach_id;
  }
  return undef;
}

sub find_activity_id_for_flag {
  my ($self, $flag) = @_;
  my $flagtype_name = $flag->type->name;
  my $date          = $flag->modification_date;
  my $setter_id     = $flag->setter->id;
  my $stream        = $self->activity_stream;

  # unfortunately bugs_activity treats all flag changes as the same field, so
  # we don't have an object_id to match on

  if (!exists $self->{activity_cache}->{flag}->{$flag->id}) {
    foreach my $change_set (reverse @$stream) {
      foreach my $activity (@{$change_set->{activity}}) {

        # match by user, timestamp, and flag-type name
        next unless $activity->{who}->id == $setter_id && $activity->{when} eq $date;
        foreach my $change (@{$activity->{changes}}) {
          next
            unless $change->{fieldname} eq 'flagtypes.name'
            && $change->{flagtype_name} eq $flagtype_name;
          $self->{activity_cache}->{flag}->{$flag->id} = $change_set->{id};
          return $change_set->{id};
        }
      }
    }

    # if we couldn't find the flag in bugs_activity it means it was set
    # during bug creation
    $self->{activity_cache}->{flag}->{$flag->id} = 'c0';
  }
  return $self->{activity_cache}->{flag}->{$flag->id};
}

# comments are processed first, so there's no need to merge into existing entries
sub _add_comment_to_stream {
  my ($stream, $time, $user_id, $comment) = @_;
  my $rh
    = {time => $time, user_id => $user_id, comment => $comment, activity => [],};
  if ( $comment->type == CMT_ATTACHMENT_CREATED
    || $comment->type == CMT_ATTACHMENT_UPDATED)
  {
    $rh->{attach_id} = $comment->extra_data;
  }
  push @$stream, $rh;
}

sub _add_activity_to_stream {
  my ($stream, $time, $user_id, $data) = @_;
  foreach my $entry (@$stream) {
    next unless $entry->{time} == $time && $entry->{user_id} == $user_id;
    $entry->{cc_only} = $entry->{cc_only} && $data->{cc_only};
    push @{$entry->{activity}}, $data;
    return;
  }
  push @$stream,
    {
    time     => $time,
    user_id  => $user_id,
    comment  => undef,
    cc_only  => $data->{cc_only},
    activity => [$data],
    };
}

sub _add_comments_to_stream {
  my ($bug, $stream) = @_;
  my $user = Bugzilla->user;
  my @treeherder_ids = map { $_->id } @{Bugzilla->treeherder_users};

  my $raw_comments = $bug->comments();
  foreach my $comment (@$raw_comments) {
    next if $comment->type == CMT_HAS_DUPE;
    my $author_id = $comment->author->id;
    next if $comment->is_private && !($user->is_insider || $user->id == $author_id);
    next
      if $comment->body eq ''
      && ($comment->work_time - 0) != 0
      && $user->is_timetracker;

    # treeherder is so spammy we hide its comments by default
    if (any { $_ == $author_id } @treeherder_ids) {
      $comment->{collapsed}        = 1;
      $comment->{collapsed_reason} = $comment->author->name;
    }

# If comment type is resolved as duplicate, do not add '...marked as duplicate...' string to comment body
    if ($comment->type == CMT_DUPE_OF) {
      $comment->set_type(0);

      # Skip if user did not supply comment also
      next if $comment->body eq '';
    }

    _add_comment_to_stream($stream, date_str_to_time($comment->creation_ts),
      $comment->author->id, $comment);
  }
}

sub _add_activities_to_stream {
  my ($bug, $stream) = @_;
  my $dbh  = Bugzilla->dbh;
  my $user = Bugzilla->user;

  # build bug activity
  my ($raw_activity)
    = $bug->can('get_activity')
    ? $bug->get_activity()
    : Bugzilla::Bug::GetBugActivity($bug->id);

  # allow other extensions to alter history
  Bugzilla::Hook::process('inline_history_activity',
    {activity => $raw_activity});

  my %attachment_cache;
  foreach my $attachment (@{$bug->attachments}) {
    $attachment_cache{$attachment->id} = $attachment;
  }

# build a list of bugs we need to check visibility of, so we can check with a single query
  my %visible_bug_ids;

  # envelope, augment and tweak
  foreach my $operation (@$raw_activity) {

    # make operation.who an object
    $operation->{who}
      = Bugzilla::User->new({name => $operation->{who}, cache => 1});

    # we need to track operations which are just cc changes
    $operation->{cc_only} = 1;

    for (my $i = 0; $i < scalar(@{$operation->{changes}}); $i++) {
      my $change = $operation->{changes}->[$i];

      # make an attachment object
      if ($change->{attachid}) {
        $change->{attach} = $attachment_cache{$change->{attachid}};
      }

      # empty resolutions are displayed as --- by default
      # make it explicit here to enable correct display of the change
      if ($change->{fieldname} eq 'resolution') {
        $change->{removed} = '---' if $change->{removed} eq '';
        $change->{added}   = '---' if $change->{added} eq '';
      }

      # make boolean fields true/false instead of 1/0
      my ($table, $field) = ('bugs', $change->{fieldname});
      if ($field =~ /^([^\.]+)\.(.+)$/) {
        ($table, $field) = ($1, $2);
      }
      my $column = $dbh->bz_column_info($table, $field);
      if ($column && $column->{TYPE} eq 'BOOLEAN') {
        $change->{removed} = '';
        $change->{added} = $change->{added} ? 'true' : 'false';
      }

      # load field object (only required for custom fields), and set the
      # field type for custom fields
      my $field_obj;
      if ($change->{fieldname} =~ /^cf_/) {
        $field_obj = Bugzilla::Field->new({name => $change->{fieldname}, cache => 1});
        $change->{fieldtype} = $field_obj->type;
      }

      # identify buglist changes
      if ($change->{fieldname} =~ /^(?:dependson|blocked|regress(?:ed_by|es)|dupe)$/
        || ($field_obj && $field_obj->type == FIELD_TYPE_BUG_ID))
      {
        $change->{buglist} = 1;
        foreach my $what (qw(removed added)) {
          my @buglist = split(/[\s,]+/, $change->{$what});
          foreach my $id (@buglist) {
            if ($id && $id =~ /^\d+$/) {
              $visible_bug_ids{$id} = 1;
            }
          }
        }
      }

      # split see-also
      if ($change->{fieldname} eq 'see_also') {
        my $url_base = Bugzilla->localconfig->urlbase;
        foreach my $f (qw( added removed )) {
          my @values;
          foreach my $value (split(/, /, $change->{$f})) {
            my ($bug_id)
              = substr($value, 0, length($url_base)) eq $url_base
              ? $value =~ /id=(\d+)$/
              : undef;
            push @values, {url => $value, bug_id => $bug_id,};
          }
          $change->{$f} = \@values;
        }
      }

      # track cc-only
      if ($change->{fieldname} ne 'cc') {
        $operation->{cc_only} = 0;
      }

      # split multiple flag changes (must be processed last)
      # set $change->{flagtype_name} to make searching the activity
      # stream for flag changes easier and quicker
      if ($change->{fieldname} eq 'flagtypes.name') {
        my @added   = split(/, /, $change->{added});
        my @removed = split(/, /, $change->{removed});
        if (scalar(@added) <= 1 && scalar(@removed) <= 1) {
          $change->{flagtype_name} = _extract_flagtype($added[0] || $removed[0]);
          next;
        }

        # remove current change
        splice(@{$operation->{changes}}, $i, 1);

        # restructure into added/removed for each flag
        my %flags;
        foreach my $flag (@added) {
          $flags{$flag}{added}   = $flag;
          $flags{$flag}{removed} = '';
        }
        foreach my $flag (@removed) {
          $flags{$flag}{added}   = '';
          $flags{$flag}{removed} = $flag;
        }

        # clone current change, modify and insert
        foreach my $flag (sort keys %flags) {
          my $flag_change = {};
          foreach my $key (keys %$change) {
            $flag_change->{$key} = $change->{$key};
          }
          $flag_change->{removed}       = $flags{$flag}{removed};
          $flag_change->{added}         = $flags{$flag}{added};
          $flag_change->{flagtype_name} = _extract_flagtype($flag);
          splice(@{$operation->{changes}}, $i, 0, $flag_change);
        }
        $i--;
      }
    }

    _add_activity_to_stream(
      $stream,
      date_str_to_time($operation->{when}),
      $operation->{who}->id, $operation
    );
  }

  # prime the visible-bugs cache
  $user->visible_bugs([keys %visible_bug_ids]);
}

sub _extract_flagtype {
  my ($value) = @_;
  return $value =~ /^(.+)[\?\-\+]/ ? $1 : undef;
}

# display 'duplicate of this bug' as an activity entry, not a comment
sub _add_duplicates_to_stream {
  my ($bug, $stream) = @_;
  my $dbh = Bugzilla->dbh;

  my $sth = $dbh->prepare("
        SELECT longdescs.who,
               UNIX_TIMESTAMP(bug_when), " . $dbh->sql_date_format('bug_when') . ",
               type,
               extra_data
          FROM longdescs
               INNER JOIN profiles ON profiles.userid = longdescs.who
         WHERE bug_id = ? AND (type = ? OR type = ?)
         ORDER BY bug_when
    ");
  $sth->execute($bug->id, CMT_HAS_DUPE, CMT_DUPE_OF);

  while (my ($who, $time, $when, $type, $dupe_id) = $sth->fetchrow_array) {
    _add_activity_to_stream(
      $stream, $time, $who,
      {
        who     => Bugzilla::User->new({id => $who, cache => 1}),
        when    => $when,
        changes => [{
          fieldname => ($type == CMT_HAS_DUPE ? 'has_dupe' : 'dupe_of'),
          added     => $dupe_id,
          buglist   => 1,
        }],
      }
    );
  }
}

1;
