#!/usr/bin/perl
# Bug change check predicates (originally CustIS Bug 68921)
# License: Dual-license GPL 3.0+ or MPL 1.1+

# Idea:
# - The user specifies a saved search.
# - Before or after each bug update, the saved search is executed on updated bugs.
#   It is also possible to run the check when only the specific bug fields are updated.
# - If bug is matched by this saved search, we assume it is in an "incorrect" state,
#   and show an error or a warning.

package Bugzilla::CheckerUtils;

use strict;
use POSIX qw(strftime);

use Bugzilla;
use Bugzilla::Constants;
use Bugzilla::Checker;
use Bugzilla::Search::Saved;
use Bugzilla::Error;
use Bugzilla::Util;

sub refresh_checker
{
    my ($query) = @_;
    my $dbh = Bugzilla->dbh;
    my ($chk) = @{ Bugzilla::Checker->match({ query_id => $query->id }) };
    $chk && $chk->update;
}

sub all
{
    my $c = Bugzilla->request_cache;
    if (!$c->{checkers})
    {
        $c->{checkers} = { map { $_->id => $_ } Bugzilla::Checker->get_all };
    }
    return $c->{checkers};
}

# Run a set of checks for a single bug. Checks are selected based on their flags,
# such that (<flags> & $mask) = $flags. I.e. check(..., CF_UPDATE, CF_FREEZE | CF_UPDATE)
# will select checks with CF_UPDATE, but without CF_FREEZE.
sub check
{
    my ($bug_id, $flags, $mask) = @_;
    $mask ||= 0;
    $flags ||= 0;
    $bug_id = $bug_id->bug_id if ref $bug_id;
    $bug_id = int($bug_id) || return;
    my $all = all();
    my $sql = [];
    my @bind;
    my ($s, $i);
    for my $checker (values %$all)
    {
        if (($checker->flags & $mask) == $flags &&
            # Do not run checkers which may be bypassed by user based on his permissions
            (!$checker->bypass_group_id || !Bugzilla->user->in_group_id($checker->bypass_group_id)))
        {
            $s = $checker->sql_code;
            push @$sql, $s;
            push @bind, $bug_id;
        }
    }
    @$sql || return [];
    $sql = join(" UNION ALL ", @$sql);
    $sql = Bugzilla->dbh->prepare_cached($sql);
    $sql->execute(@bind);
    my $checked = [];
    push @$checked, $all->{$i} while (($i) = $sql->fetchrow_array);
    return $checked;
}

# Run checks and rollback changes to the last SAVEPOINT if there are failed ones.
# If only "soft" checks are failed and Bugzilla->request_cache->{checkers_hide_error} is false,
# the warning message with a "Do what I say" button is shown and the request is terminated.
# The function returns true if all checks passed, or if the user said "do what i say" for
# non-fatal checks, and sets $bug->{passed_checkers} to the same value.
sub alert
{
    my ($bug, $is_new) = @_;
    my (@fatal, @warn);
    for (@{$bug->{failed_checkers} || []})
    {
        if ($_->triggers)
        {
            # Triggers never result in an error
        }
        elsif ($_->is_fatal)
        {
            push(@fatal, $_);
        }
        else
        {
            push(@warn, $_);
        }
    }
    my $force = 1 && Bugzilla->input_params->{force_checkers};
    if (!@fatal && (!@warn || $force))
    {
        # Either there are no errors or there are only non-fatal ones and the used clicked "DO WHAT I SAY"
        $bug->{passed_checkers} = 1;
    }
    else
    {
        my $dbh = Bugzilla->dbh;
        # Some checks failed. Roll changes back.
        $bug->{passed_checkers} = 0;
        # bugs_fulltext is non-transactional...
        if ($is_new)
        {
            $dbh->do('DELETE FROM bugs_fulltext WHERE '.$dbh->FULLTEXT_ID_FIELD.'=?', undef, $bug->bug_id);
        }
        else
        {
            $bug->_sync_fulltext;
        }
        # Rollback changes of a SINGLE bug (see process_bug.cgi)
        $dbh->bz_rollback_to_savepoint;
        if (!Bugzilla->request_cache->{checkers_hide_error})
        {
            show_checker_errors(freeze_failed_checkers([ $bug ]));
        }
    }
    return $bug->{passed_checkers};
}

# Show check error message
sub show_checker_errors
{
    my ($bugs) = @_;
    $bugs ||= saved_failed_checkers();
    return if !grep { !$_->{passed_checkers} } @$bugs;
    # Recheck force/fatal for the case of mass update when show_checker_errors() is called once in the end
    my $fatal = 1 && (grep { grep { $_->{is_fatal} } @{$_->{failed_checkers} || []} } @$bugs);
    my $force = 1 && Bugzilla->input_params->{force_checkers};
    if ($fatal || !$force)
    {
        if (Bugzilla->error_mode != ERROR_MODE_WEBPAGE)
        {
            my $info = [
                    map { {
                    bug_id => $_->bug_id,
                    errors => [ map { $_->message } grep { !$_->triggers } @{$_->{failed_checkers}} ]
                } }
                grep { @{$_->{failed_checkers} || []} } @$bugs
            ];
            ThrowUserError('checks_failed', { bugs => $info });
        }
        @{Bugzilla->result_messages} = ();
        delete Bugzilla->input_params->{force_checkers};
        Bugzilla->template->process("bug/process/verify-checkers.html.tmpl", {
            script_name => Bugzilla->cgi->script_name,
            failed => $bugs,
            allow_commit => !$fatal,
        }) || ThrowTemplateError(Bugzilla->template->error);
        exit;
    }
}

sub freeze_failed_checkers
{
    my $failedbugs = shift;
    $failedbugs && @$failedbugs || return undef;
    return [
        map { {
            bug_id => $_->bug_id,
            failed_checkers => [ map { {
                name => $_->name,
                is_fatal => $_->is_fatal,
                is_freeze => $_->is_freeze,
                message => $_->message,
            } } @{$_->{failed_checkers}} ]
        } } grep { @{$_->{failed_checkers} || []} } @$failedbugs
    ];
}

sub filter_failed_checkers
{
    my ($checkers, $changes, $bug) = @_;
    # Filter failed checkers by changes
    my @rc;
    for my $checker (@$checkers)
    {
        if ($checker->triggers)
        {
            # Skip triggers
            push @rc, $checker;
            next;
        }
        my $e = $checker->except_fields;
        my $ok = 1;
        if ($checker->deny_all)
        {
            # Allow only changes of except_fields to except values
            for my $field (keys %$changes)
            {
                # If the field is not listed in except_fields, OR
                # if there is a specific value in except_fields and our one is not equal
                if (!exists $e->{$field} || (defined $e->{$field} && !grep { $_ eq $changes->{$field}->[1] } list($e->{$field})))
                {
                    $ok = 0;
                    last;
                }
            }
        }
        else
        {
            # Forbid changes of except_fields to except values
            for my $field (keys %$e)
            {
                # work_time_date is a special pseudo-field meaning addition of backdated worktime
                # the value of this pseudo-field is the date before which it is forbidden to fix worktime
                # for example except_fields={work_time_date=2010-09-01} means forbid fixing worktime
                # for dates before 2010-09-01
                if ($field eq 'work_time_date')
                {
                    my $today_date = strftime('%Y-%m-%d', localtime);
                    my $min_backdate = $e->{$field} || $today_date;
                    my $min_comment_date;
                    foreach my $comment (@{$bug->{added_comments} || []})
                    {
                        my $cd = $comment->{bug_when} || $today_date;
                        if (!$min_comment_date || $cd lt $min_comment_date)
                        {
                            $min_comment_date = $cd;
                        }
                    }
                    if ($min_comment_date && $min_backdate gt $min_comment_date)
                    {
                        $ok = 0;
                        last;
                    }
                }
                elsif ($changes->{$field} && (!defined $e->{$field} || grep { $_ eq $changes->{$field}->[1] } list($e->{$field})))
                {
                    $ok = 0;
                    last;
                }
            }
        }
        push @rc, $checker unless $ok;
    }
    @$checkers = @rc;
}

# Run triggers for bug $bug from $bug->{failed_checkers}
sub run_triggers
{
    my ($bug) = @_;
    my $modified = 0;
    for (my $i = $#{$bug->{failed_checkers}}; $i >= 0; $i--)
    {
        my $checker = $bug->{failed_checkers}->[$i];
        if ($checker->triggers)
        {
            # FIXME Only "add CC" and "clear flag" triggers are supported by now, but it's not that hard to support more
            if ($checker->triggers->{add_cc})
            {
                for (split /[\s,]+/, $checker->triggers->{add_cc})
                {
                    $bug->add_cc($_);
                    $modified = 1;
                }
            }
            if ($checker->triggers->{clear_flags})
            {
                my %del_flags = map { $_ => 1 } split /[\s,]*,+[\s,]*/, $checker->triggers->{clear_flags};
                for my $flag (@{$bug->flags})
                {
                    if ($del_flags{$flag->name})
                    {
                        $bug->make_dirty;
                        Bugzilla::Flag->set_flag($bug, {
                            id => $flag->id,
                            status => 'X',
                            requestee => $flag->requestee && $flag->requestee->login,
                        });
                        $modified = 1;
                    }
                }
            }
        }
        # FIXME Show information about the applied trigger (use result_messages)
        splice @{$bug->{failed_checkers}}, $i, 1;
    }
    return $modified;
}

sub saved_failed_checkers
{
    my ($create_if_not_found) = @_;
    for my $msg (@{ Bugzilla->result_messages })
    {
        if ($msg->{message} eq 'checkers_failed')
        {
            return $msg->{failed_checkers} ||= [];
        }
    }
    if ($create_if_not_found)
    {
        my $list = [];
        Bugzilla->add_result_message({
            message => 'checkers_failed',
            failed_checkers => $list,
        });
        return $list;
    }
    return undef;
}

# hooks:

sub bug_pre_update
{
    my ($args) = @_;
    my $bug = $args->{bug};
    # Run checks BEFORE updating the bug. These are "freezers" and triggers.
    $bug->{failed_checkers} = check($bug->bug_id, CF_FREEZE | CF_UPDATE, CF_FREEZE | CF_UPDATE);
    run_triggers($bug);
    return 1;
}

sub bug_end_of_update
{
    my ($args) = @_;

    my $bug = $args->{bug};
    my $changes = { %{ $args->{changes} } }; # copy hash
    $changes->{longdesc} = $args->{bug}->{added_comments} && @{ $args->{bug}->{added_comments} }
        ? [ '', scalar @{$args->{bug}->{added_comments}} ] : undef;

    # run checks AFTER updating the bug (normal "checkers")
    push @{$bug->{failed_checkers}}, @{ check($bug->bug_id, CF_UPDATE, CF_FREEZE | CF_UPDATE) };

    # filter by changes
    if (@{$bug->{failed_checkers}})
    {
        filter_failed_checkers($bug->{failed_checkers}, $changes, $bug);
    }

    # complain and roll changes back if there are failed checks
    if (@{$bug->{failed_checkers}})
    {
        alert($bug);
        # remember failed checks in result_messages
        push @{saved_failed_checkers(1)}, @{ freeze_failed_checkers([ $bug ]) };
    }

    return 1;
}

sub bug_end_of_create
{
    my ($args) = @_;
    my $bug = $args->{bug};
    # We don't filter anything by field changes when creating bugs!
    $bug->{failed_checkers} = check($bug->bug_id, CF_CREATE, CF_CREATE);
    if (@{$bug->{failed_checkers}})
    {
        alert($bug, 1);
    }
    # Triggers are ran in a separate UPDATE on bug creation.
    if (run_triggers($bug))
    {
        $bug->update;
    }
    return 1;
}

sub savedsearch_post_update
{
    my ($args) = @_;
    refresh_checker($args->{search});
    return 1;
}

# Refresh cached SQL code of checks at the end of checksetup.pl
sub install_before_final_checks
{
    my ($args) = @_;
    print "Refreshing Checkers SQL...\n" if !$args->{silent};
    for (Bugzilla::Checker->get_all)
    {
        eval { $_->update };
        if ($@)
        {
            warn $@;
        }
    }
    return 1;
}

1;
__END__
