# 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.

use 5.14.0;
use strict;
use warnings;

use FindBin qw($RealBin);
use lib "$RealBin/../lib", "$RealBin/../../local/lib/perl5";

use Data::Dumper;
use QA::Util;
use QA::Tests qw(PRIVATE_BUG_USER STANDARD_BUG_TESTS);
use Storable qw(dclone);
use Test::More tests => 937;

use constant NONEXISTENT_BUG => 12_000_000;

###############
# Subroutines #
###############

# We have to generate different values for each RPC client, so we
# have a function to generate the tests for each client.
sub get_tests {
  my ($config, $rpc) = @_;

  # update doesn't support logged-out users.
  my @tests = grep { $_->{user} } @{STANDARD_BUG_TESTS()};

  my ($public_bug, $second_bug) = $rpc->bz_create_test_bugs();
  my ($public_id, $second_id) = ($public_bug->{id}, $second_bug->{id});

  # Add aliases to both bugs
  $public_bug->{alias} = random_string(40);
  $second_bug->{alias} = random_string(40);
  my $alias_tests = [
    {
      user => 'editbugs',
      args => {ids => [$public_id], alias => $public_bug->{alias}},
      test => 'Add alias to public bug'
    },
    {
      user => 'editbugs',
      args => {ids => [$second_id], alias => $second_bug->{alias}},
      test => 'Add alias to second bug'
    },
  ];
  $rpc->bz_run_tests(tests => $alias_tests, method => 'Bug.update');

  my $comment_call
    = $rpc->bz_call_success('Bug.comments', {ids => [$public_id, $second_id]});
  $public_bug->{comment}
    = $comment_call->result->{bugs}->{$public_id}->{comments}->[0];
  $second_bug->{comment}
    = $comment_call->result->{bugs}->{$second_id}->{comments}->[0];

  push(
    @tests,
    (
      {
        args  => {ids => [$public_id]},
        error => 'You must log in',
        test  => 'Logged-out users cannot call update'
      },

      # FIXME We need a permissions test for canedit, but it's so uncommonly
      # used that it's not a high priority.
    )
  );

  my %valid = valid_values($config, $public_bug, $second_bug);
  my $valid_value_tests = valid_values_to_tests(\%valid, $public_bug);
  push(@tests, @$valid_value_tests);

  my %invalid = invalid_values($public_bug, $second_bug);
  my $invalid_value_tests = invalid_values_to_tests(\%invalid, $public_bug);
  push(@tests, @$invalid_value_tests);

  return \@tests;
}

sub valid_values {
  my ($config, $public_bug, $second_bug) = @_;

  my $admin      = $config->{'admin_user_login'};
  my $second_id  = $second_bug->{id};
  my $comment_id = $public_bug->{comment}->{id};
  my $bug_uri
    = $config->{browser_url} . '/'
    . $config->{bugzilla_installation}
    . '/show_bug.cgi?id=';

  my %values = (
    alias       => [{value => random_string(20)},],
    assigned_to => [{value => $config->{'unprivileged_user_login'}}],
    blocks      => [
      {
        value => {set => [$second_id]},
        added => $second_id,
        test  => 'set to second bug'
      },
      {
        value   => {remove => [$second_id]},
        added   => '',
        removed => $second_id,
        test    => 'remove second bug'
      },
      {
        value   => {add => [$second_id]},
        added   => $second_id,
        removed => '',
        test    => 'add second bug'
      },
      {
        value   => {set => []},
        added   => '',
        removed => $second_id,
        test    => 'set to nothing'
      },
    ],

    cc => [
      {
        value   => {add => [$admin]},
        added   => $admin,
        removed => '',
        test    => 'add admin'
      },
      {
        value   => {remove => [$admin]},
        added   => '',
        removed => $admin,
        test    => 'remove admin'
      },
      {
        value      => {remove => [$admin]},
        test       => "removing user who isn't on the list works",
        no_changes => 1
      },
    ],

    is_cc_accessible =>
      [{value => 0, test => 'set to 0'}, {value => 1, test => 'set to 1'},],

    comment => [
      {value => {body => random_string(100)}, test => 'public'},
      {
        value => {body => random_string(100), is_private => 1},
        user  => PRIVATE_BUG_USER,
        test  => 'private'
      },
    ],

    comment_is_private => [
      {
        value => {$comment_id => 1},
        user  => PRIVATE_BUG_USER,
        test  => 'make description private'
      },
      {
        value => {$comment_id => 0},
        user  => PRIVATE_BUG_USER,
        test  => 'make description public'
      },
    ],

    component => [{value => 'c2'}],

    deadline => [
      {value => '2037-01-01'},
      {value => '', removed => '2037-01-01', test => 'remove'},
    ],

    dupe_of => [{value => $second_id},],

    estimated_time => [
      {value => '10.0'},
      {value => '0.0', removed => '10.0', test => 'set to zero'},
    ],

    groups => [
      {
        value => {add => ['Master']},
        user  => 'admin',
        added => 'Master',
        test  => 'add Master'
      },
      {
        value   => {remove => ['Master']},
        user    => 'admin',
        added   => '',
        removed => 'Master',
        test    => 'remove Master'
      },
    ],

    keywords => [
      {
        value => {add => ['test-keyword-1']},
        test  => 'add one',
        added => 'test-keyword-1'
      },
      {
        value => {set => ['test-keyword-1', 'test-keyword-2']},
        test  => 'set two',
        added => 'test-keyword-2'
      },
      {
        value   => {remove => ['test-keyword-1']},
        removed => 'test-keyword-1',
        added   => '',
        test    => 'remove one'
      },
      {
        value   => {set => []},
        removed => 'test-keyword-2',
        added   => '',
        test    => 'set to empty'
      },
      {
        value      => {remove => ['test-keyword-2']},
        test       => 'removing removed keyword does nothing',
        no_changes => 1
      },
    ],

    op_sys => [{value => 'All'},],

    platform => [{value => 'All'},],

    priority => [{value => 'Normal'},],

    product => [
      {
        value => 'C2 Forever',
        extra =>
          {component => 'Helium', version => 'unspecified', target_milestone => '---',},
        test => 'move to C2 Forever'
      },

      # This also tests that the extra fields transfer over properly
      # when they have identical names in both products.
      {
        value => $public_bug->{product},
        extra => {component => $public_bug->{component}},
        test  => 'move back to original product'
      },
    ],

    qa_contact => [
      {value => $admin}, {value => '', test => 'set blank', removed => $admin},

      # Reset to the original so that reset_qa_contact can also be tested.
      {value => $public_bug->{qa_contact}},
    ],

    remaining_time => [{value => '1000.50'}, {value => 0},],

    reset_assigned_to =>
      [{value => 1, field => 'assigned_to', added => $config->{permanent_user}},],

    reset_qa_contact => [{value => 1, field => 'qa_contact', added => ''},],

    resolution => [
      {
        value => 'FIXED',
        extra => {status => 'RESOLVED'},
        test  => 'to RESOLVED FIXED'
      },
      {value => 'INVALID', test => 'just resolution'},
    ],

    see_also => [
      {
        value   => {add => [$bug_uri . $second_id]},
        added   => $bug_uri . $second_id,
        removed => '',
        test    => 'add local bug URI'
      },
      {
        value   => {remove => [$bug_uri . $second_id]},
        removed => $bug_uri . $second_id,
        added   => '',
        test    => 'remove local bug URI'
      },
      {
        value =>
          {remove => ['http://landfill.bugzilla.org/bugzilla-tip/show_bug.cgi?id=1']},
        no_changes => 1,
        test       => 'removing non-existent URI works'
      },
      {
        value      => {add => ['']},
        no_changes => 1,
        test       => 'adding an empty string to see_also does nothing'
      },
      {
        value      => {add => [undef]},
        no_changes => 1,
        test       => 'adding a null to see_also does nothing'
      },
    ],

    status => [

      # At this point, due to previous tests, the status is RESOLVED,
      # so changing to CONFIRMED is our only real option if we want to
      # test a simple open status.
      {value => 'CONFIRMED'},
    ],

    severity => [{value => 'critical'},],

    summary => [{value => random_string(100)},],

    target_milestone => [{value => 'AnotherMS2'},],

    url => [{value => 'http://' . random_string(20) . '/'},],

    version => [{value => 'Another2'},],

    whiteboard => [{value => random_string(1000)},],

    work_time => [

      # FIXME: work_time really needs to start showing up in the changes
      # hash.
      {value => '1.2', no_changes => 1},
      {value => '-1.2', test => 'negative value', no_changes => 1},
    ],
  );

  $values{depends_on}            = $values{blocks};
  $values{is_creator_accessible} = $values{is_cc_accessible};

  return %values;
}

sub valid_values_to_tests {
  my ($valid_values, $public_bug) = @_;

  my @tests;
  foreach my $field (sort keys %$valid_values) {
    my @tests_valid = @{$valid_values->{$field}};
    foreach my $item (@tests_valid) {
      my $desc = $item->{test} || 'valid value';
      my %args = (
        ids    => [$public_bug->{id}],
        $field => $item->{value},
        %{$item->{extra} || {}},
      );
      my %test = (
        user  => 'editbugs',
        args  => \%args,
        field => $field,
        test  => "$field: $desc"
      );
      foreach my $item_field (qw(no_changes added removed field user)) {
        next if !exists $item->{$item_field};
        $test{$item_field} = $item->{$item_field};
      }
      push(@tests, \%test);
    }
  }

  return \@tests;
}

sub invalid_values {
  my ($public_bug, $second_bug) = @_;

  my $public_id = $public_bug->{id};
  my $second_id = $second_bug->{id};

  my $comment_id        = $public_bug->{comment}->{id};
  my $second_comment_id = $second_bug->{comment}->{id};

  my %values = (
    alias => [
      {
        value => random_string(41),
        error => 'aliases cannot be longer than',
        test  => 'alias cannot be too long'
      },
      {
        value => $second_bug->{alias},
        error => 'Please choose another alias or visit that bug and change its alias',
        test  => 'duplicate alias fails'
      },
      {
        value => 123456,
        error => 'at least one letter',
        test  => 'numeric alias fails'
      },
      {
        value => random_string(20),
        ids   => [$public_id, $second_id],
        error => 'aliases when modifying multiple',
        test  => 'setting alias on multiple bugs fails'
      },
    ],

    assigned_to => [
      {
        value => random_string(20),
        error => 'There is no user named',
        test  => 'changing assigned_to to invalid user fails'
      },
      {
        value => '',
        error => 'you must provide an address for the new assignee',
        test  => 'empty assigned_to fails'
      },

      # FIXME Also check strict_isolation at some point in the future,
      # perhaps.
    ],

    blocks => [
      {
        value => {add => [NONEXISTENT_BUG]},
        error => 'does not exist',
        test  => 'Non-existent bug number fails in deps'
      },
      {
        value => {add => [$public_id]},
        error => 'block itself or depend on itself',
        test  => "can't add this bug itself in a dep field"
      },

      # FIXME Could use strict_isolation checks at some point.
      # FIXME Could use a dependency_loop_multi test.
    ],

    cc => [
      {
        value => {add => [random_string(20)]},
        error => 'There is no user named',
        test  => 'adding invalid user to cc fails'
      },
      {
        value => {remove => [random_string(20)]},
        error => 'There is no user named',
        test  => 'removing invalid user from cc fails'
      },
    ],

    comment => [
      {
        value => {body => random_string(100_000)},
        error => 'cannot be longer',
        test  => 'comment too long'
      },
      {
        value => {body => random_string(100), is_private => 1},
        error => 'comments or attachments as private',
        test  => 'normal user cannot add private comments'
      },
    ],

    comment_is_private => [
      {
        value => {$comment_id => 1},
        error => 'comments or attachments as private',
        test  => 'normal user cannot make a comment private'
      },
      {
        value => {$second_comment_id => 1},
        error => 'You tried to modify the privacy of comment',
        user  => PRIVATE_BUG_USER,
        test  => 'cannot change privacy on a comment on another bug'
      },
    ],

    component => [
      {
        value => '',
        error => 'you must first choose a component',
        test  => 'empty component fails'
      },
      {
        value => random_string(20),
        error => 'There is no component named',
        test  => 'invalid component fails'
      },
    ],

    deadline => [
      {
        value => random_string(20),
        error => 'is not a legal date',
        test  => 'Non-date fails in deadline'
      },
      {
        value => '2037',
        error => 'is not a legal date',
        test  => 'year alone fails in deadline'
      },
    ],

    dupe_of => [
      {
        value => undef,
        error => 'dup_id was not defined',
        test  => 'undefined dupe_of fails'
      },
      {
        value => NONEXISTENT_BUG,
        error => 'does not exist',
        test  => 'Cannot dup to a nonexistent bug'
      },
      {
        value => $public_id,
        error => 'as a duplicate of itself',
        test  => 'Cannot dup bug to itself'
      },
    ],

    estimated_time => [
      {
        value => -1,
        error => 'less than the minimum allowable value',
        test  => 'negative estimated_time fails'
      },
      {
        value => 100_000_000,
        error => 'more than the maximum allowable value',
        test  => 'too-large estimated_time fails'
      },
      {
        value => random_string(20),
        error => 'is not a numeric value',
        test  => 'non-numeric estimated_time fails'
      },

      # We use PRIVATE_BUG_USER because he can modify the bug, but
      # can't change time-tracking fields.
      {
        value => '100',
        user  => PRIVATE_BUG_USER,
        error => 'only a user with the required permissions',
        test  => 'non-timetracker can not set estimated_time'
      },
    ],

    groups => [
      {
        value => {add => ['Master']},
        error =>
          'either this group does not exist, or you are not allowed to restrict bugs to this group',
        test => "adding group we don't have access to but is valid fails"
      },
      {
        value => {add => ['QA-Selenium-TEST']},
        error =>
          'either this group does not exist, or you are not allowed to restrict bugs to this group',
        test => 'adding valid group that is not in this product fails'
      },
      {
        value => {add => [random_string(20)]},
        error =>
          'either this group does not exist, or you are not allowed to restrict bugs to this group',
        test => 'adding non-existent group fails'
      },
      {
        value => {remove => [random_string(20)]},
        error =>
          'either this group does not exist, or you are not allowed to remove bugs from this group',
        test => 'removing non-existent group fails'
      },
    ],

    keywords => [
      {
        value => {add => [random_string(20)]},
        error => 'See the list of available keywords',
        test  => 'adding invalid keyword fails'
      },
      {
        value => {remove => [random_string(20)]},
        error => 'See the list of available keywords',
        test  => 'removing invalid keyword fails'
      },
      {
        value => {set => [random_string(20)]},
        error => 'See the list of available keywords',
        test  => 'setting invalid keyword fails'
      },
    ],

    op_sys => [
      {
        value => random_string(20),
        error => 'There is no',
        test  => 'invalid op_sys fails'
      },
      {value => '', error => 'You must select/enter', test => 'blank op_sys fails'},
    ],

    product => [
      {
        value => random_string(60),
        error => "does not exist or you aren't authorized",
        test  => 'invalid product fails'
      },
      {
        value => '',
        error => 'You must select/enter a product',
        test  => 'moving to blank product fails'
      },
      {
        value => 'TestProduct',
        error => 'There is no component named',
        test  => 'moving products without other fields fails'
      },
      {
        value => 'QA-Selenium-TEST',
        extra => {component => 'QA-Selenium-TEST'},
        error => "does not exist or you aren't authorized",
        test  => 'moving to inaccessible product fails'
      },
      {
        value => 'QA Entry Only',
        error => "does not exist or you aren't authorized",
        test  => 'moving to product where ENTRY is denied fails'
      },
    ],

    qa_contact => [
      {
        value => random_string(20),
        error => 'There is no user named',
        test  => 'changing qa_contact to invalid user fails'
      },
    ],

    remaining_time => [
      {
        value => -1,
        error => 'less than the minimum allowable value',
        test  => 'negative remaining_time fails'
      },
      {
        value => 100_000_000,
        error => 'more than the maximum allowable value',
        test  => 'too-large remaining_time fails'
      },
      {
        value => random_string(20),
        error => 'is not a numeric value',
        test  => 'non-numeric remaining_time fails'
      },

      # We use PRIVATE_BUG_USER because he can modify the bug, but
      # can't change time-tracking fields.
      {
        value => '100',
        user  => PRIVATE_BUG_USER,
        error => 'only a user with the required permissions',
        test  => 'non-timetracker can not set remaining_time'
      },
    ],

    # We do all the failing resolution tests on the second bug,
    # because we want to be sure that we're starting from an open
    # status.
    resolution => [
      {
        value => random_string(20),
        ids   => [$second_id],
        extra => {status => 'RESOLVED'},
        error => 'There is no Resolution named',
        test  => 'invalid resolution fails'
      },
      {
        value => 'FIXED',
        ids   => [$second_id],
        error => 'You cannot set a resolution for open bugs',
        test  => 'setting resolution on open bug fails'
      },
      {
        value => 'DUPLICATE',
        ids   => [$second_id],
        extra => {status => 'RESOLVED'},
        error => 'id to mark this bug as a duplicate',
        test  => 'setting DUPLICATE without dup_id fails'
      },
      {
        value => '',
        ids   => [$second_id],
        extra => {status => 'RESOLVED'},
        error => 'A valid resolution is required',
        test  => 'blank resolution fails with closed status'
      },
    ],

    see_also => [
      {
        value => {add => [random_string(20)]},
        error => 'is not a valid bug number nor an alias',
        test  => 'random string fails in see_also'
      },
      {
        value => {add => ['http://landfill.bugzilla.org/']},
        error => 'See Also URLs should point to one of',
        test  => 'no show_bug.cgi in see_also URI'
      },
    ],

    status => [
      {
        value => random_string(20),
        error => 'There is no status named',
        test  => 'invalid status fails'
      },
      {
        value => '',
        error => 'You must select/enter a status',
        test  => 'blank status fails'
      },

      # We use the second bug for this because we can guarantee that
      # it is open.
      {
        value => 'VERIFIED',
        ids   => [$second_id],
        extra => {resolution => 'FIXED'},
        error => 'You are not allowed to change the bug status from',
        test  => 'invalid transition fails'
      },
    ],

    summary => [
      {
        value => random_string(300),
        error => 'The text you entered in the Summary field is too long',
        test  => 'too-long summary fails'
      },
      {
        value => '',
        error => 'You must enter a summary for this bug',
        test  => 'blank summary fails'
      },
    ],

    work_time => [
      {
        value => 100_000_000,
        error => 'more than the maximum allowable value',
        test  => 'too-large work_time fails'
      },
      {
        value => random_string(20),
        error => 'is not a numeric value',
        test  => 'non-numeric work_time fails'
      },

      # We use PRIVATE_BUG_USER because he can modify the bug, but
      # can't change time-tracking fields.
      {
        value => '10',
        user  => PRIVATE_BUG_USER,
        error => 'only a user with the required permissions',
        test  => 'non-timetracker can not set work_time'
      },
    ],
  );

  $values{depends_on} = $values{blocks};

  foreach my $field (qw(platform priority severity target_milestone version)) {
    my $tests = dclone($values{op_sys});
    foreach my $test (@$tests) {
      $test->{test} =~ s/op_sys/$field/g;
    }
    $values{$field} = $tests;
  }

  return %values;
}

sub invalid_values_to_tests {
  my ($invalid_values, $public_bug) = @_;

  my @tests;
  foreach my $field (sort keys %$invalid_values) {
    my @tests_invalid = @{$invalid_values->{$field}};
    foreach my $item (@tests_invalid) {
      my %args = (
        ids => $item->{ids} || [$public_bug->{id}],
        $field => $item->{value},
        %{$item->{extra} || {}},
      );
      push(
        @tests,
        {
          user  => $item->{user} || 'editbugs',
          args  => \%args,
          error => $item->{error},
          test  => $item->{test}
        }
      );
    }
  }

  return \@tests;
}

###############
# Main Script #
###############

my ($config, $xmlrpc, $jsonrpc, $jsonrpc_get) = get_rpc_clients();

$jsonrpc_get->bz_call_fail(
  'Bug.update',
  {ids => ['public_bug']},
  'must use HTTP POST',
  'update fails over GET'
);

sub post_success {
  my ($call, $t, $rpc) = @_;
  return if $t->{no_changes};
  my $field = $t->{field};
  return if !$field;

  my @bugs = @{$call->result->{bugs}};
  foreach my $bug (@bugs) {
    if ($field =~ /^comment/) {
      _check_comment($bug, $field, $t, $rpc);
    }
    else {
      _check_changes($bug, $field, $t);
    }
  }
}

sub _check_changes {
  my ($bug, $field, $t) = @_;

  my $changes = $bug->{changes}->{$field};
  ok(defined $changes, "$field was changed") or diag Dumper($bug, $t);

  my $new_value = $t->{added};
  $new_value = $t->{args}->{$field} if !defined $new_value;
  _test_value($changes->{added}, $new_value, $field, 'added');

  if (defined $t->{removed}) {
    _test_value($changes->{removed}, $t->{removed}, $field, 'removed');
  }
}

sub _test_value {
  my ($got, $expected, $field, $type) = @_;
  if ($field eq 'estimated_time' or $field eq 'remaining_time') {
    cmp_ok($got, '==', $expected, "$field: $type is correct");
  }
  else {
    is($got, $expected, "$field: $type is correct");
  }
}

sub _check_comment {
  my ($bug, $field, $t, $rpc) = @_;
  my $bug_id   = $bug->{id};
  my $call     = $rpc->bz_call_success('Bug.comments', {ids => [$bug_id]});
  my $comments = $call->result->{bugs}->{$bug_id}->{comments};

  if ($field eq 'comment_is_private') {
    my $first_private = $comments->[0]->{is_private};
    my ($expected) = values %{$t->{args}->{comment_is_private}};
    cmp_ok($first_private, '==', $expected, 'description privacy is correct');
  }
  else {
    my $last_comment = $comments->[-1];
    my $expected     = $t->{args}->{comment}->{body};
    is($last_comment->{text}, $expected, 'comment added correctly');
  }

}

foreach my $rpc ($jsonrpc, $xmlrpc) {
  $rpc->bz_run_tests(
    tests        => get_tests($config, $rpc),
    method       => 'Bug.update',
    post_success => \&post_success
  );
}
