The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Smolder::Control::Projects;
use base 'Smolder::Control';
use strict;
use warnings;
use CGI::Application::Plugin::Stream qw(stream_file);
use DateTime;
use Smolder::DB::Project;
use Smolder::DB::SmokeReport;
use Smolder::Conf qw(HostName);
use Smolder::DB::TestFile;
use Smolder::DB::TestFileResult;
use Smolder::Util;
use Smolder::Control::Public::Auth;
use Smolder::Constraints qw(
  enum_value
  length_max
  unsigned_int
  bool
  file_mtype
  smoke_report_tags
);
use Exception::Class;
use HTML::TagCloud;
use URI::Escape qw(uri_escape);
use XML::Atom::SimpleFeed;

=head1 NAME

Smolder::Control::Projects

=head1 DESCRIPTION

Controller module that deals with actions associated with projects and test reports

=cut

sub setup {
    my $self = shift;
    $self->start_mode('show_all');
    $self->run_modes(
        [
            qw(
              show_all
              public
              add_report
              process_add_report
              smoke_reports
              smoke_report
              report_details
              test_file_report_details
              platform_options
              architecture_options
              smoke_test_validity
              admin_settings
              process_admin_settings
              delete_tag
              details
              tap_archive
              tap_stream
              mute_testfiles
              comment_testfiles
              test_file_history
              feed
              )
        ]
    );
}

=head1 RUN MODES

=head2 smoke_test_validity

Set or unset the C<invalid> flag on a given smoke report. Uses the
F<Projects/smoke_report_details.tmpl> template.

=cut

sub smoke_test_validity {
    my $self   = shift;
    my $report = Smolder::DB::SmokeReport->retrieve($self->param('id'));
    return $self->error_message("Smoke Report does not exist!")
      unless $report;

    # only project admins can do this
    return $self->error_message("Not an admin of this project!")
      unless $report->project->is_admin($self->developer);

    # make sure it's not too long or malicious
    my $form = {
        required           => [qw(invalid)],
        optional           => [qw(invalid_reason)],
        constraint_methods => {
            invalid        => bool(),
            invalid_reason => length_max(255),
        }
    };
    my $results = Data::FormValidator->check($self->query, $form)
      || return $self->error_message('Invalid data!');
    my $valid = $results->valid();

    # now update the DB
    $report->invalid($valid->{invalid});
    $report->invalid_reason($valid->{invalid_reason});
    $report->update();

    # notify the user
    $self->add_message(msg => "Report #$report has been marked as "
          . ($valid->{invalid} ? 'invalid' : 'valid')
          . ".");
    return $self->tt_process(
        'Projects/smoke_report_details.tmpl',
        {report => $report, project => $report->project},
    );
}

=head2 platform_options

Returns an HTML list sutiable for a Prototype based "Autocomplete" form
of exising platform otpions

=cut

sub platform_options {
    my $self = shift;
    return $self->auto_complete_results(
        Smolder::DB::SmokeReport->column_values('platform', $self->query->param('platform'),));
}

=head2 architecture_options

Returns an HTML list sutiable for a Prototype based "Autocomplete" form
of exising architecture otpions

=cut

sub architecture_options {
    my $self = shift;
    return $self->auto_complete_results(
        Smolder::DB::SmokeReport->column_values(
            'architecture', $self->query->param('architecture'),
        )
    );
}

=head2 add_report

Shows the form to allow the developer to add a new smoke report to a project.
Uses the C<Projects/add_report.tmpl> template.

=cut

sub add_report {
    my ($self, $tt_params) = @_;
    $tt_params ||= {};

    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    # make sure ths developer is a member of this project, or it's a public project
    # that allows anonymous uploads
    if (!$project->has_developer($self->developer)) {
        if ($project->public && !$project->allow_anon) {
            return $self->error_message('Project does not allow anonymous reports');
        } elsif (!$project->public) {
            return $self->error_message('Unauthorized for this project');
        }
    }

    $tt_params->{project} = $project;
    return $self->tt_process($tt_params);
}

=head2 process_add_report

Process the incoming information from the C<add_report> mode. If validation passes
we upload the file and update the database. The report is stored for permanent storage,
and summary information is extracted and inserted into the database.
If successful, redirects the user to the "Recent Smoke Tests" screen for the same
project.

If the newly uploaded report causes the C<ProjectFullReportsMax> configuration limit 
to be reached then any full reports over that limit will be purged. Their summary data 
will still be retained in the database, but the compressed XML file which contains the 
details will be removed, and the reports details will no longer be accessible.

=cut

sub _goto_login {
    my $self = shift;
    my $q    = $self->query;
    $self->header_type('redirect');
    my $url = "/app/public_auth/login";
    $self->header_props(-url => $url, -status => '401');
    return "Redirecting to $url";
}

sub process_add_report {
    my $self    = shift;
    my $q       = $self->query;
    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    if ($q->param('username') && $q->param('password')) {
        Smolder::Control::Public::Auth::do_login($self, $q->param('username'),
            $q->param('password'));
    }

    # make sure ths developer is a member of this project, or it's a public project
    # that allows anonymous uploads
    if (!$project->has_developer($self->developer)) {
        if ($project->public && !$project->allow_anon) {
            return $self->error_message('Project does not allow anonymous reports');
        } elsif (!$project->public) {
            return $self->error_message('Unauthorized for this project');
        }
    }

    my $form = {
        required           => [qw(report_file)],
        optional           => [qw(architecture platform comments tags revision)],
        constraint_methods => {
            architecture => length_max(255),
            platform     => length_max(255),
            revision     => length_max(255),
            comments     => length_max(1000),
            report_file  => file_mtype(
                qw(
                  application/x-gzip
                  application/x-gtar
                  application/x-tar
                  application/x-zip
                  multipart/x-gzip
                  )
            ),
            tags => smoke_report_tags(),
        },
    };

    my $results = $self->check_rm('add_report', $form)
      || return $self->check_rm_error_page;
    my $valid = $results->valid();

    my $report;
    eval {
        $report = Smolder::DB::SmokeReport->upload_report(
            file         => $valid->{report_file},
            developer    => $self->developer,
            project      => $project,
            architecture => $valid->{architecture},
            platform     => $valid->{platform},
            comments     => $valid->{comments},
            revision     => $valid->{revision},
        );
    };

    # is this an exception we can deal with?
    my $e;
    if ($e = Exception::Class->caught('Smolder::Exception::InvalidTAP')) {
        $self->log->warning($e->error . "\n" . $e->trace->as_string);
        return $self->add_report({invalid_report_file => 1});
    } elsif ($e = Exception::Class->caught('Smolder::Exception::InvalidArchive')) {
        $self->log->warning($e->error . "\n" . $e->trace->as_string);
        return $self->add_report({invalid_report_file => 1});
    } else {
        $e = Exception::Class->caught();
        if ($e) {
            ref $e && $e->isa('Exception::Class') ? $e->rethrow : die $e;
        }
    }

    # add the tags if present
    if ($valid->{tags}) {
        $report->add_tag($_) foreach @{$valid->{tags}};
    }

    # redirect to our recent reports
    $self->header_type('redirect');
    my $url = "/app/projects/smoke_reports/$project";
    $self->header_add(-uri => $url);
    return "Reported #$report added.\nRedirecting to $url";
}

=head2 smoke_report

Shows the summary info for a given smoke_report. Uses the F<Projects/smoke_report.tmpl>
template.

=cut

sub smoke_report {
    my $self  = shift;
    my $query = $self->query();

    my $smoke = Smolder::DB::SmokeReport->retrieve($self->param('id'));
    return $self->error_message('Smoke report does not exist')
      unless $smoke;
    my $project = $smoke->project;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($project);

    return $self->tt_process({report => $smoke, project => $project});
}

=head2 smoke_reports

Shows a list of smoke reports for a given project based on the limit, offset
and tag parameters. Uses the F<Projects/smoke_reports.tmpl>
template.

=cut

sub smoke_reports {
    my ($self, $tt_params) = @_;
    $tt_params ||= {};
    my $query = $self->query();

    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($project);

    # prevent malicious limit and offset values
    my $form = {
        optional           => [qw(limit offset tag)],
        constraint_methods => {
            limit  => unsigned_int(),
            offset => unsigned_int(),
        },
    };
    return $self->error_message('Something fishy')
      unless Data::FormValidator->check($query, $form);

    $tt_params->{project} = $project;
    $tt_params->{limit} =
      defined $query->param('limit')
      ? $query->param('limit')
      : (Smolder::Conf->get('ReportsPerPage') || 5);
    $tt_params->{offset} = $query->param('offset') || 0;
    $tt_params->{tag}    = $query->param('tag')    || undef;

    return $self->tt_process($tt_params);
}

=head2 report_details

Show the full report for download.  Does not use a template but simply
outputs the pregenerated file.

=cut

sub report_details {
    my $self   = shift;
    my $report = Smolder::DB::SmokeReport->retrieve($self->param('id'));

    if (Smolder::Conf->get('AutoRefreshReports')) {
        $report->update_from_tap_archive();
    }

    return $self->error_message('Test Report does not exist')
      unless $report;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($report->project);

    my $tt_params = {
        tap     => ${$report->html},
        project => $report->project,
        report  => $report,
    };

    return $self->tt_process('Projects/tap.tmpl', $tt_params),;
}

=head2 test_file_report_details

Show the details of an individual test file. This is called from
the HTML page generated by L<report_details> by an AJAX call.

=cut

sub test_file_report_details {
    my $self   = shift;
    my $report = Smolder::DB::SmokeReport->retrieve($self->param('id'));
    return $self->error_message('Test Report does not exist')
      unless $report;
    my $num = $self->param('type') || 0;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($report->project);
    return $report->html_test_detail($num);
}

=head2 tap_archive

Return the TAP archive for a given report to the browser

=cut

sub tap_archive {
    my $self   = shift;
    my $report = Smolder::DB::SmokeReport->retrieve($self->param('id'));
    return $self->error_message('Test Report does not exist')
      unless $report;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($report->project);

    return $self->stream_file($report->file);
}

=head2 tap_stream

Return the TAP stream for a given report and given stream index number to the browser.

=cut

sub tap_stream {
    my $self      = shift;
    my $report_id = $self->param('id');
    my $tap_index = $self->param('stream_index');
    my $report    = Smolder::DB::SmokeReport->retrieve($report_id);
    return $self->error_message("Test Report $report_id does not exist")
      unless $report;

    # make sure ths developer is a member of this project
    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($report->project);
    my $output = $report->tap_stream($tap_index);
    $$output = '<pre>' . $$output . '</pre>';
    return $output;
}

=head2 show_all

Show all of the projects this developer is associated with and a menu for
each one.

=cut

sub show_all {
    my $self     = shift;
    my @projects = $self->developer->projects;
    my $public   = $self->public_projects;
    
    # only keep the stuff in public that's not in our project list
    my @non_overlap_public;
    foreach my $pub_proj (@$public) {
        my $found = 0;
        foreach my $proj (@projects) {
            if( $proj->id == $pub_proj->id ) {
                $found = 1;
                next;
            }
        }
        push(@non_overlap_public, $pub_proj) if !$found;
    }
    return $self->tt_process({my_projects => \@projects, public_projects => \@non_overlap_public});
}

=head2 public

Show all of the public projects with and a menu for each one.

=cut

sub public {
    my $self   = shift;
    return $self->tt_process({public_projects => $self->public_projects});
}

=head2 admin_settings

If this developer is the admin of a project then show them a form to update some
project specific settings. Uses the F<Projects/admin_settings_form.tmpl>
and the F<Projects/admin_settings.tmpl> templates.

=cut

sub admin_settings {
    my ($self, $tt_params) = @_;

    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    # only show if this developer is an admin of this project
    return $self->error_message('You are not an admin of this Project!')
      unless $project->is_admin($self->developer);

    # if we have something then we're coming from another sub so fill the
    # form from the CGI params
    my $out;
    if ($tt_params) {
        $tt_params->{project}   = $project;
        $tt_params->{tag_cloud} = $self->_project_tag_cloud($project);
        $out                    = HTML::FillInForm->new()->fill(
            scalarref =>
              $self->tt_process('Projects/admin_settings_form.tmpl', $tt_params),
            fobject => $self->query(),
        );

    } else {

        # else we weren't passed anything, then we need to fill in the form
        # from the DB
        $tt_params              = {};
        $tt_params->{project}   = $project;
        $tt_params->{tag_cloud} = $self->_project_tag_cloud($project);
        my $fill_data = {
            default_platform => $project->default_platform,
            default_arch     => $project->default_arch,
            allow_anon       => $project->allow_anon,
            graph_start      => $project->graph_start,
        };
        $out = HTML::FillInForm->new()->fill(
            scalarref => $self->tt_process($tt_params),
            fdat      => $fill_data,
        );
    }
    return $out;
}

sub _project_tag_cloud {
    my ($self, $project, $url) = @_;
    my @tags = $project->tags(with_counts => 1);
    if (@tags) {
        my $cloud = HTML::TagCloud->new();
        foreach (@tags) {
            my $tag_url = $url ? "$url?tag=" . uri_escape($_->{tag}) : 'javascript:void(0)';
            $cloud->add($_->{tag}, $tag_url, $_->{count});
        }
        return $cloud->html_and_css(100);
    } else {
        return '';
    }
}

=head2 process_admin_settings 

Process the incoming information from the C<admin_settings> mode. If
it passes validation then update the database. If successful, returns
to the C<admin_settings> mode.

=cut

sub process_admin_settings {
    my $self    = shift;
    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    # only process if this developer is an admin of this project
    return $self->error_message('You are not an admin of this Project!')
      unless $project->is_admin($self->developer);

    # validate the incoming data
    my $form = {
        required           => [qw(allow_anon graph_start)],
        optional           => [qw(default_arch default_platform)],
        constraint_methods => {
            allow_anon       => bool(),
            default_arch     => length_max(255),
            default_platform => length_max(255),
            graph_start      => enum_value('project', 'graph_start'),
        },
    };
    my $results = $self->check_rm('admin_settings', $form)
      || return $self->check_rm_error_page;
    my $valid = $results->valid();

    # set and save
    foreach my $field (qw(allow_anon default_arch default_platform graph_start)) {
        $project->$field($valid->{$field});
    }
    $project->update();

    $self->add_message(msg => "Project settings successfully updated.",);
    return $self->admin_settings({success => 1});
}

=head2 delete_tag

Deletes a tag that is associated with a given Project. If validation
passes the database is updated and all smoke reports that were associated
with this tag are either re-assigned or simply not associated with a
tag (depending on what the project admin chooses). Returns to the
C<admin_settings> mode if successful.

=cut

sub delete_tag {
    my $self    = shift;
    my $project = Smolder::DB::Project->retrieve($self->param('id'));
    return $self->error_message('Project does not exist')
      unless $project;

    # only process if this developer is an admin of this project
    return $self->error_message('You are not an admin of this Project!')
      unless $project->is_admin($self->developer);

    my $query = $self->query();
    my ($tag, $replacement) = map { $query->param($_) } qw(tag replacement);

    if ($replacement) {
        # change the tag
        $project->change_tag($tag, $replacement);
        $self->add_message(msg => "Tag '$tag' was successfully replaced by '$replacement'.");
    } else {
        # delete the old tag
        $project->delete_tag($tag);
        $self->add_message(
            msg => "Tag '$tag' successfully deleted from project '" . $project->name . "'.");
    }

    return $self->admin_settings();
}

=head2 details

Shows the details of a project.

=cut

sub details {
    my $self = shift;
    my $id   = $self->param('id');
    my $proj = Smolder::DB::Project->retrieve($id);

    if ($proj) {
        return $self->error_message('Unauthorized for this project')
          unless $self->can_see_project($proj);
        my $tag_cloud = $self->_project_tag_cloud($proj, "/app/projects/smoke_reports/$proj");
        return $self->tt_process({project => $proj, tag_cloud => $tag_cloud});
    } else {
        return $self->error_message('That project does not exist!');
    }
}

=head2 mute_testfiles 

Mute a group of test files.

=cut

sub mute_testfiles {
    my $self   = shift;
    my $q      = $self->query;
    my $report = Smolder::DB::SmokeReport->retrieve($self->param('id'));

    # Determine which test files were checked
    my @testfile_ids = $q->param('testfiles');
    my @testfiles = map { Smolder::DB::TestFile->retrieve($_) } @testfile_ids;

    # mute those files
    my $days = $q->param('days');
    my $mute_until_time = DateTime->now(time_zone => 'local')->add(days => $days)->truncate(to => 'day')->epoch;
    foreach my $test_file (@testfiles) {
        return $self->error_message('Unauthorized for this project')
          unless $test_file->project->has_developer($self->developer);
        $test_file->mute_until($mute_until_time);
        $test_file->update;
    }

    # Recompute page after any bulk action
    $report->update_from_tap_archive();
    $self->add_message(msg => $days ? "Test files successfully muted." : "Test files successfully un-muted.");
    my $return_to = $q->param('return_to') || 'report_details';
    return $self->$return_to;
}

=head2 comment_testfiles 

Create a comment on a group of test files.

=cut

sub comment_testfiles {
    my $self = shift;
    my $q    = $self->query;

    # attach the comment to the given testfiles
    my @testfiles    = map { Smolder::DB::TestFile->retrieve($_) } ($q->param('testfiles'));
    my $comment      = $q->param('comment');
    foreach my $test_file (@testfiles) {
        return $self->error_message('Unauthorized for this project')
          unless $test_file->project->has_developer($self->developer);
        Smolder::DB::TestFileComment->create(
            {
                project   => $test_file->project,
                developer => $self->developer,
                comment   => $comment,
                test_file => $test_file,
            }
        );
    }

    $self->add_message(msg => 'Comment successfully added');
    $self->param(id => $testfiles[0]->project->id);
    $self->param(test_file_id => $testfiles[0]->id);
    return $self->test_file_history;
}

=head2 test_file_history

Show history of a particular test file in this project

=cut

sub test_file_history {
    my $self         = shift;
    my $q            = $self->query;
    my $project      = Smolder::DB::Project->retrieve($self->param('id'));
    my $test_file    = Smolder::DB::TestFile->retrieve($self->param('test_file_id'));

    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($project);

    # prevent malicious limit and offset values
    my $form = {
        optional           => [qw(limit offset)],
        constraint_methods => {
            limit  => unsigned_int(),
            offset => unsigned_int(),
        },
    };
    return $self->error_message('Something fishy')
      unless Data::FormValidator->check($q, $form);

    my $limit  = $q->param('limit')  || 20;
    my $offset = $q->param('offset') || 0;
    my @test_file_results = Smolder::DB::TestFileResult->search_where(
        {
            project   => $project,
            test_file => $test_file
        },
        {
            limit_dialect => 'LimitOffset',
            offset        => $offset,
            limit         => $limit,
            order_by      => 'added DESC'
        }
    );
    my $tt_params = {
        project   => $project,
        test_file => $test_file,
        results   => \@test_file_results
    };
    return $self->tt_process('Projects/test_file_history.tmpl', $tt_params);
}

=head2 feed

Will return an XML data feed (Atom) to the browser. The 5 most recent smoke
reports for a project are included in this feed. An optional C<type>
can also be specified which is can either be C<all> or C<failures>.Only
projects that have been marked as C<enable_feed> will appear in any feed.

=cut

sub feed {
    my $self = shift;
    my @binds;
    my $sql = qq/
        SELECT sr.* FROM smoke_report sr
        JOIN project p ON (sr.project = p.id)
        WHERE p.enable_feed = 1 AND p.id = ?
    /;
    my $id      = $self->param('id');
    my $project = Smolder::DB::Project->retrieve($id);

    return $self->error_message('Unauthorized for this project')
      unless $self->can_see_project($project);

    my $type    = $self->param('type');
    push(@binds, $id);

    if ($type and $type eq 'failed') {
        $sql .= ' AND sr.failed = 1 ';
    }

    $sql .= ' ORDER BY sr.added DESC LIMIT 5';

    my $sth = Smolder::DB::SmokeReport->db_Main->prepare_cached($sql);
    $sth->execute(@binds);
    my @reports = Smolder::DB::SmokeReport->sth_to_objects($sth);

    $self->header_props(-type => 'text/xml');

    my $updated;
    if (@reports) {
        $updated = $reports[0]->added;
    } else {
        $updated = DateTime->now();
    }

    my $feed = XML::Atom::SimpleFeed->new(
        title   => '[' . $project->name . '] Smolder - ' . HostName,
        link    => Smolder::Util::url_base,
        id      => Smolder::Util::url_base,
        updated => $updated->strftime('%FT%TZ'),
    );

    foreach my $report (@reports) {
        my $link =
            Smolder::Util::url_base() . '/app/'
          . 'projects/smoke_report/'
          . $report->id;
        $feed->add_entry(
            title => '#'
              . $report->id . ' - '
              . ($report->failed ? 'Failed' : 'New')
              . ' Smoke Report',
            author  => $report->developer->username,
            link    => $link,
            id      => $link,
            summary => $report->summary,
            updated => $report->added->strftime('%FT%TZ'),
        );
    }
    return $feed->as_string();
}

1;