package Dist::Zilla::Plugin::Travis::TestRelease;

our $AUTHORITY = 'cpan:BBYRD'; # AUTHORITY
our $VERSION = '1.11'; # VERSION
# ABSTRACT: makes sure repo passes Travis tests before release

#############################################################################
# Modules

use Moose;
use sanity;

use Net::Travis::API::UA;
use Date::Parse 'str2time';
use Date::Format 'time2str';
use Storable 'dclone';

use Dist::Zilla::Util::Git::Bundle;

with 'Dist::Zilla::Role::BeforeRelease';

#############################################################################
# Private Attributes

has _git_bundle => (
   is       => 'ro',
   isa      => 'Dist::Zilla::Util::Git::Bundle',
   lazy     => 1,
   init_arg => undef,
   handles  => { _git => 'git' },
   default  => sub {
      my $self = shift;
      Dist::Zilla::Util::Git::Bundle->new(
         ### XXX: deep recursion on the branches
         zilla         => $self->zilla,
         logger        => $self->logger,
         #branch        => $self->branch,
         remote_name   => $self->remote,
         #remote_branch => $self->remote_branch,
      );
   },
);
has _travis_ua => (
   is       => 'ro',
   isa      => 'Net::Travis::API::UA',
   lazy     => 1,
   init_arg => undef,
   default  => sub {
      my $ua = Net::Travis::API::UA->new;
      no strict 'vars';
      $ua->agent(__PACKAGE__."/$VERSION ");  # prepend our own UA string
      return $ua;
   }
);

#############################################################################
# Public Attributes

has branch => (
   is      => 'ro',
   lazy    => 1,
   default => sub { join('/', 'release_testing', shift->_git_bundle->current_branch) },
);
has remote_branch => (
   is      => 'ro',
   lazy    => 1,
   default => sub { shift->branch },
);
has remote => (
   is      => 'ro',
   default => 'origin',
);
has slug => (
   is      => 'ro',
   lazy    => 1,
   default => sub {
      my $self = shift;
      my @github = $self->_git_bundle->acquire_github_repo_info;
      $self->log_fatal(["Remote '%s' is not a Github repo!", $self->remote]) unless @github;

      return join('/', @github);
   },
);
has create_builddir => (
   is      => 'ro',
   isa     => 'Bool',
   default => 0,
);
has open_status_url => (
   is      => 'ro',
   isa     => 'Bool',
   default => 0,
);

#############################################################################
# Methods

my %RESULT_MAP = (
   '' => 'Error',
   0  => 'Pass',
   1  => 'Fail',
);

sub before_release {
   my ($self, $tgz) = @_;

   my $gb   = $self->_git_bundle;

   $gb->branch($self->branch);
   $gb->_remote_branch($self->remote_branch);

   # Stash, checkout, apply, untar, update, push, reset
   my $prev_repo_info;
   $gb->dirty_branch_push(
      create_builddir => $self->create_builddir,
      stash_reason    => 'Travis release testing',
      tgz             => $tgz,
      pre_remote_code => sub {
         # Check TravisCI prior to the push to make sure the distro works and exists
         $self->log('Checking Travis CI...');
         $prev_repo_info = $self->travisci_api_get_repo;
      },
   );

   # Start a clock
   my $start_time = time;
   $self->_set_time_prefix($start_time);
   $self->log('Checking Travis CI for test details...');

   # Run through the API polling loop
   my $repo_info;
   while (1) {
      $repo_info = $self->travisci_api_get_repo;
      $self->_set_time_prefix($start_time);

      if (
         $repo_info->{last_build_number} >  $prev_repo_info->{last_build_number} ||
         $repo_info->{last_build_id}     != $prev_repo_info->{last_build_id}
      ) {
         my $build_time = str2time($repo_info->{last_build_started_at}, 'GMT');
         $self->log([ 'Build %u started at %s', $repo_info->{last_build_number}, time2str('%l:%M%p', $build_time) ]);

         my $url = sprintf 'https://travis-ci.org/%s/builds/%u', $self->slug, $repo_info->{last_build_id};
         $self->log("Status URL: $url");

         # Open it up in a browser
         if ($self->open_status_url) {
            require Browser::Open;
            Browser::Open::open_browser($url);
         }

         last;
      }

      $self->log_fatal("Waited over 5 minutes and TravisCI still hasn't even seen the new commit yet!") if (time - $start_time > 5*60);
      sleep 10;
   };

   $self->_set_time_prefix($start_time);

   # Get a relative idea of test duration for polling time
   my $last_test_duration =
      str2time($prev_repo_info->{last_build_finished_at}, 'GMT') -
      str2time($prev_repo_info->{last_build_started_at},  'GMT')
   ;
   my $poll_freq = int($last_test_duration / 4);
   $poll_freq = 10  if $poll_freq < 10;
   $poll_freq = 120 if $poll_freq > 120;

   # Another polling loop with the build status
   my $prev_build_info;
   $start_time = time;
   while (1) {
      my $build_info = $self->travisci_api_get_build($repo_info->{last_build_id});
      $self->_set_time_prefix($start_time);

      # aggregiate job details
      my @matrix   = @{ $build_info->{matrix} };
      my @started  = grep { defined $_->{started_at}  } @matrix;
      my @finished = grep { defined $_->{finished_at} } @matrix;

      my $pending    = int @matrix  - @started;
      my $running    = int @started - @finished;
      my $finished   = int @finished;
      my $passed     = int scalar grep { $RESULT_MAP{ $_->{result} } eq 'Pass' } @finished;
      my $allow_fail = int scalar grep { $RESULT_MAP{ $_->{result} } ne 'Pass' &&  $_->{allow_failure} } @finished;
      my $failed     = int scalar grep { $RESULT_MAP{ $_->{result} } ne 'Pass' && !$_->{allow_failure} } @finished;

      my @job_status;
      push @job_status, sprintf('%u jobs pending', $pending) if $pending;
      push @job_status, sprintf('%u jobs running', $running) if $running;

      if ($finished) {
         push @job_status, sprintf('%u jobs finished', $finished);
         push @job_status,
            $allow_fail ?
               sprintf('%u/%u/%u jobs passed/failed/allowed to fail', $passed, $failed, $allow_fail) :
               sprintf('%u/%u jobs passed/failed', $passed, $failed)
         ;
      }

      # fake a $prev_build_info if it doesn't exist
      unless ($prev_build_info) {
         $prev_build_info = dclone $build_info;
         foreach my $job (@{ $prev_build_info->{matrix} }) {
            $job->{started_at}  = undef;
            $job->{finished_at} = undef;
            $job->{result}      = undef;
         }
      }

      # individual job updates
      my %prev_matrix = map { $_->{number} => $_ } @{ $prev_build_info->{matrix} };

      foreach my $job (@matrix) {
         my $prev = $prev_matrix{ $job->{number} };

         # jobs that have started
         if (!defined $prev->{started_at} && defined $job->{started_at}) {
            my $config = $job->{config};
            my @config_label;
            push @config_label, 'Perl '.$config->{perl} if $config->{perl};
            push @config_label, $config->{env} if $config->{env};

            $self->log(['   Job %s%s started at %s',
               $job->{number},
               (@config_label ? ' ('.join(', ', @config_label).')' : ''),
               time2str('%l:%M%p', str2time($job->{started_at}, 'GMT') ),
            ]);
         }

         # jobs that have finished
         if    (!defined $prev->{finished_at} && defined $job->{finished_at}) {
            my $result = $RESULT_MAP{ $job->{result} };
            $result .= ' (allowed)' if ($result eq 'Fail' && $job->{allow_failure});

            my $finish_time = str2time($job->{finished_at}, 'GMT');

            $self->log(['   Job %s finished at %s with a status of %s', $job->{number}, time2str('%l:%M%p', $finish_time), $result ]);
         }
      }
      $prev_build_info = $build_info;

      $self->log('   === '.join(', ', @job_status));

      ### NOTE: Travis' Fast Finish feature will already speed up the build status, so just honor that feature and don't use
      ### $failed to determine if the build is finished.

      # figure out if we need to exit or not
      if ($build_info->{state} eq 'finished') {
         my $result = $RESULT_MAP{ $build_info->{result} };

         my $finish_time = str2time($build_info->{finished_at}, 'GMT');

         $self->log([ 'Build %u finished at %s with a status of %s', $build_info->{number}, time2str('%l:%M%p', $finish_time), $result ]);
         $self->logger->set_prefix('');

         $self->log_fatal("Travis CI build didn't pass!") unless $result eq 'Pass';
         last;
      }

      $self->log_fatal("Waited over an hour and the build still hasn't finished yet!") if (time - $start_time > 60*60);

      $poll_freq = int($poll_freq / 2) if ($finished and not $pending || time - $start_time >= $last_test_duration * 0.75);
      $poll_freq = 10 if $poll_freq < 10;
      sleep $poll_freq;
   };

   return 1;
}

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

   my $result = $self->_travis_ua->get('/repos/'.$self->slug);
   $self->log_fatal("Travis CI API reported back with: $result") unless $result->content_type eq 'application/json';

   my $repo_info = $result->content_json;
   $self->log_fatal("Travis CI cannot find your repository; did you forget to configure it?") if $repo_info->{file} eq 'not found';

   return $repo_info;
}

sub travisci_api_get_build {
   my ($self, $build_id) = @_;

   my $result = $self->_travis_ua->get('/repos/'.$self->slug."/builds/$build_id");
   $self->log_fatal("Travis CI API reported back with: $result") unless $result->content_type eq 'application/json';

   my $build_info = $result->content_json;
   $self->log_fatal("Travis CI cannot find your build?!?") if $build_info->{file} eq 'not found';

   return $build_info;
}

sub _set_time_prefix {
   my ($self, $start_time) = @_;
   my $time_diff = time - $start_time;
   my $min = int($time_diff / 60);
   my $sec = $time_diff % 60;

   $self->logger->set_prefix( sprintf('(%02u:%02u) ', $min, $sec) );
}

__PACKAGE__->meta->make_immutable;
42;

__END__

=pod

=encoding UTF-8

=head1 NAME

Dist::Zilla::Plugin::Travis::TestRelease - makes sure repo passes Travis tests before release

=head1 SYNOPSIS

    ;;; Test DZIL
 
    [Travis::TestRelease]
    ; defaults typically work fine
 
    ;;; Test DZIL+build
 
    [TravisYML]
    support_builddir = 1
    ; (optional) only test with Travis::TestRelease
    dzil_branch = /^release_testing\/.*/
 
    [Travis::TestRelease]
    create_builddir = 1

=head1 DESCRIPTION

Tired of releasing a module only to discover that it failed Travis tests?  This plugin solves that problem.

It pushes a release testing branch to Travis, monitors the testing, and aborts the release if the Travis build fails.  It also
supports testing the non-DZIL build directory directly.

L<TravisYML|Dist::Zilla::Plugin::TravisYML> is not required to use this plugin, even for build testing, but is still recommended.

=head1 DETAILS

Starting the process requires creating and pushing a release testing branch to GitHub.  This is done through a series of git
commands, designed to work with the dirtiest of branch states:

=over

=item 1.

If there are any "dirty files", even untracked files, put them into a git stash.

=item 2.

Create or hard reset the release testing branch to match the main branch.

=item 3.

Apply the stash (if created) and add any new files.

=item 4.

If a build directory is requested, extract it into .buildE<sol>testing, and add it.

=item 5.

Commit the changes.

=item 6.

Force push the testing branch to the repo.

=item 7.

Switch back to the main branch.

=item 8.

If any files were stashed, apply it back to the branch.  This is done by hard resetting the main branch to the stash (don't panic;
it's just a copy of the branch with a few extra commits), and then walking the index back to the refhash it was at originally.

=back

As you may notice, the testing branch is subject to harsh and overwriting changes, so B<don't rely on the branch for anything except
release testing!>

After the branch is pushed, the plugin checks Travis (via API) to make sure it starts testing.  Monitoring stops when Travis says
the build is finished.  Use of L<Travis' Fast Finish option|http://docs.travis-ci.com/user/build-configuration/#Fast-finishing> is
recommended to speed up test results.

=head1 OPTIONS

=head2 remote

Name of the remote repo.

The default is C<<< origin >>>.

=head2 branch

Name of the local release testing branch.  B<Do not use this branch for anything except release testing!>

The default is C<<< release_testing/$current_branch >>>.

=head2 remote_branch

Name of the remote branch.

The default is whatever the C<<< branch >>> option is set to.

=head2 slug

Name of the "slug", or usernameE<sol>repo combo, that will be used to query the test details.  For example, this distro has a slug of
C<<< SineSwiper/Dist-Zilla-TravisCI >>>.

The default is auto-detection of the slug using the remote URL.

=head2 create_builddir

Boolean; determines whether to create a build directory or not.  If turned on, the plugin will create a C<<< .build/testing >>>
directory in the testing branch to be used for build testing.  Whether this is actually used depends on the C<<< .travis.yml >>> file.
For example, L<TravisYML|Dist::Zilla::Plugin::TravisYML>'s C<<< support_builddir >>> switch will create a Travis matrix in the YAML file
to test both DZIL and build directories on the same git branch.  If you're not using that plugin, you should at least implement
something similar to make use of dual DZIL+build tests.

Default is off.

=head2 open_status_url

Boolean; determines whether to automatically open the Travis CI build status URL to a browser, using L<Browser::Open>.

Default is off.

=head1 CAVEATS

Plugin order is important.  Since Travis build testing takes several minutes, this should be one of the last C<<< before_release >>>
plugins in your dist.ini, after plugins like L<TestRelease|Dist::Zilla::Plugin::TestRelease>, but still just before
L<ConfirmRelease|Dist::Zilla::Plugin::ConfirmRelease>.

The amount of git magic and little used switches required to make and push the branch to GitHub may be considered questionable by
some, especially force pushes and hard resets.  But it is all required to make sure testing occurs from any sort of branch state.
And it works.

Furthermore, it's not the job of this plugin to make sure the branch state is clean.  Use plugins like
L<Git::Check|Dist::Zilla::Plugin::Git::Check> for that.

=head1 AVAILABILITY

The project homepage is L<https://github.com/SineSwiper/Dist-Zilla-TravisCI>.

The latest version of this module is available from the Comprehensive Perl
Archive Network (CPAN). Visit L<http://www.perl.com/CPAN/> to find a CPAN
site near you, or see L<https://metacpan.org/module/Dist::Zilla::TravisCI/>.

=head1 AUTHOR

Brendan Byrd <bbyrd@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2014 by Brendan Byrd.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)

=cut
