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

# This module represents a chart.
#
# Note that it is perfectly legal for the 'lines' member variable of this
# class (which is an array of Bugzilla::Series objects) to have empty members
# in it. If this is true, the 'labels' array will also have empty members at
# the same points.
package Bugzilla::Chart;

use 5.14.0;
use strict;
use warnings;

use Bugzilla::Error;
use Bugzilla::Util;
use Bugzilla::Series;

use Date::Format;
use Date::Parse;
use List::Util qw(max);

sub new {
  my $invocant = shift;
  my $class    = ref($invocant) || $invocant;

  # Create a ref to an empty hash and bless it
  my $self = {};
  bless($self, $class);

  if ($#_ == 0) {

    # Construct from a CGI object.
    $self->init($_[0]);
  }
  else {
    die("CGI object not passed in - invalid number of args \($#_\)($_)");
  }

  return $self;
}

sub init {
  my $self = shift;
  my $cgi  = shift;

  # The data structure is a list of lists (lines) of Series objects.
  # There is a separate list for the labels.
  #
  # The URL encoding is:
  # line0=67&line0=73&line1=81&line2=67...
  # &label0=B+/+R+/+CONFIRMED&label1=...
  # &select0=1&select3=1...
  # &cumulate=1&datefrom=2002-02-03&dateto=2002-04-04&ctype=html...
  # &gt=1&labelgt=Grand+Total
  foreach my $param ($cgi->param()) {

    # Store all the lines
    if ($param =~ /^line(\d+)$/) {
      foreach my $series_id ($cgi->param($param)) {
        detaint_natural($series_id) || ThrowCodeError("invalid_series_id");
        my $series = new Bugzilla::Series($series_id);
        push(@{$self->{'lines'}[$1]}, $series) if $series;
      }
    }

    # Store all the labels
    if ($param =~ /^label(\d+)$/) {
      $self->{'labels'}[$1] = $cgi->param($param);
    }
  }

  # Store the miscellaneous metadata
  $self->{'cumulate'} = $cgi->param('cumulate') ? 1 : 0;
  $self->{'gt'}       = $cgi->param('gt')       ? 1 : 0;
  $self->{'labelgt'}  = $cgi->param('labelgt');
  $self->{'datefrom'} = $cgi->param('datefrom');
  $self->{'dateto'}   = $cgi->param('dateto');

  # If we are cumulating, a grand total makes no sense
  $self->{'gt'} = 0 if $self->{'cumulate'};

  # Make sure the dates are ones we are able to interpret
  foreach my $date ('datefrom', 'dateto') {
    if ($self->{$date}) {
      $self->{$date} = str2time($self->{$date})
        || ThrowUserError("illegal_date", {date => $self->{$date}});
    }
  }

  # datefrom can't be after dateto
  if ( $self->{'datefrom'}
    && $self->{'dateto'}
    && $self->{'datefrom'} > $self->{'dateto'})
  {
    ThrowUserError(
      'misarranged_dates',
      {
        'datefrom' => scalar $cgi->param('datefrom'),
        'dateto'   => scalar $cgi->param('dateto')
      }
    );
  }
}

# Alter Chart so that the selected series are added to it.
sub add {
  my $self       = shift;
  my @series_ids = @_;

  # Get the current size of the series; required for adding Grand Total later
  my $current_size = scalar($self->getSeriesIDs());

  # Count the number of added series
  my $added = 0;

  # Create new Series and push them on to the list of lines.
  # Note that new lines have no label; the display template is responsible
  # for inventing something sensible.
  foreach my $series_id (@series_ids) {
    my $series = new Bugzilla::Series($series_id);
    if ($series) {
      push(@{$self->{'lines'}}, [$series]);
      push(@{$self->{'labels'}}, "");
      $added++;
    }
  }

  # If we are going from < 2 to >= 2 series, add the Grand Total line.
  if (!$self->{'gt'}) {
    if ($current_size < 2 && $current_size + $added >= 2) {
      $self->{'gt'} = 1;
    }
  }
}

# Alter Chart so that the selections are removed from it.
sub remove {
  my $self     = shift;
  my @line_ids = @_;

  foreach my $line_id (@line_ids) {
    if ($line_id == 65536) {

      # Magic value - delete Grand Total.
      $self->{'gt'} = 0;
    }
    else {
      delete($self->{'lines'}->[$line_id]);
      delete($self->{'labels'}->[$line_id]);
    }
  }
}

# Alter Chart so that the selections are summed.
sub sum {
  my $self     = shift;
  my @line_ids = @_;

  # We can't add the Grand Total to things.
  @line_ids = grep(!/^65536$/, @line_ids);

  # We can't add less than two things.
  return if scalar(@line_ids) < 2;

  my @series;
  my $label         = "";
  my $biggestlength = 0;

  # We rescue the Series objects of all the series involved in the sum.
  foreach my $line_id (@line_ids) {
    my @line = @{$self->{'lines'}->[$line_id]};

    foreach my $series (@line) {
      push(@series, $series);
    }

    # We keep the label that labels the line with the most series.
    if (scalar(@line) > $biggestlength) {
      $biggestlength = scalar(@line);
      $label         = $self->{'labels'}->[$line_id];
    }
  }

  $self->remove(@line_ids);

  push(@{$self->{'lines'}},  \@series);
  push(@{$self->{'labels'}}, $label);
}

sub data {
  my $self = shift;
  $self->{'_data'} ||= $self->readData();
  return $self->{'_data'};
}

# Convert the Chart's data into a plottable form in $self->{'_data'}.
sub readData {
  my $self = shift;
  my @data;
  my @maxvals;

  # Note: you get a bad image if getSeriesIDs returns nothing
  # We need to handle errors better.
  my $series_ids = join(",", $self->getSeriesIDs());

  return [] unless $series_ids;

  # Work out the date boundaries for our data.
  my $dbh = Bugzilla->dbh;

  # The date used is the one given if it's in a sensible range; otherwise,
  # it's the earliest or latest date in the database as appropriate.
  my $datefrom
    = $dbh->selectrow_array("SELECT MIN(series_date) "
      . "FROM series_data "
      . "WHERE series_id IN ($series_ids)");
  $datefrom = str2time($datefrom);

  if ($self->{'datefrom'} && $self->{'datefrom'} > $datefrom) {
    $datefrom = $self->{'datefrom'};
  }

  my $dateto
    = $dbh->selectrow_array("SELECT MAX(series_date) "
      . "FROM series_data "
      . "WHERE series_id IN ($series_ids)");
  $dateto = str2time($dateto);

  if ($self->{'dateto'} && $self->{'dateto'} < $dateto) {
    $dateto = $self->{'dateto'};
  }

  # Convert UNIX times back to a date format usable for SQL queries.
  my $sql_from = time2str('%Y-%m-%d', $datefrom);
  my $sql_to   = time2str('%Y-%m-%d', $dateto);

  # Prepare the query which retrieves the data for each series
  my $query
    = "SELECT "
    . $dbh->sql_to_days('series_date') . " - "
    . $dbh->sql_to_days('?')
    . ", series_value "
    . "FROM series_data "
    . "WHERE series_id = ? "
    . "AND series_date >= ?";
  if ($dateto) {
    $query .= " AND series_date <= ?";
  }

  my $sth = $dbh->prepare($query);

  my $gt_index   = $self->{'gt'} ? scalar(@{$self->{'lines'}}) : undef;
  my $line_index = 0;

  $maxvals[$gt_index] = 0 if $gt_index;

  my @datediff_total;

  foreach my $line (@{$self->{'lines'}}) {

    # Even if we end up with no data, we need an empty arrayref to prevent
    # errors in the PNG-generating code
    $data[$line_index]    = [];
    $maxvals[$line_index] = 0;

    foreach my $series (@$line) {

      # Get the data for this series and add it on
      if ($dateto) {
        $sth->execute($sql_from, $series->{'series_id'}, $sql_from, $sql_to);
      }
      else {
        $sth->execute($sql_from, $series->{'series_id'}, $sql_from);
      }
      my $points = $sth->fetchall_arrayref();

      foreach my $point (@$points) {
        my ($datediff, $value) = @$point;
        $data[$line_index][$datediff] ||= 0;
        $data[$line_index][$datediff] += $value;
        if ($data[$line_index][$datediff] > $maxvals[$line_index]) {
          $maxvals[$line_index] = $data[$line_index][$datediff];
        }

        $datediff_total[$datediff] += $value;

        # Add to the grand total, if we are doing that
        if ($gt_index) {
          $data[$gt_index][$datediff] ||= 0;
          $data[$gt_index][$datediff] += $value;
          if ($data[$gt_index][$datediff] > $maxvals[$gt_index]) {
            $maxvals[$gt_index] = $data[$gt_index][$datediff];
          }
        }
      }
    }

    # We are done with the series making up this line, go to the next one
    $line_index++;
  }

  # calculate maximum y value
  if ($self->{'cumulate'}) {

    # Make sure we do not try to take the max of an array with undef values
    my @processed_datediff;
    while (@datediff_total) {
      my $datediff = shift @datediff_total;
      push @processed_datediff, $datediff if defined($datediff);
    }
    $self->{'y_max_value'} = max(@processed_datediff);
  }
  else {
    $self->{'y_max_value'} = max(@maxvals);
  }
  $self->{'y_max_value'} |= 1;    # For log()

  # Align the max y value:
  #  For one- or two-digit numbers, increase y_max_value until divisible by 8
  #  For larger numbers, see the comments below to figure out what's going on
  if ($self->{'y_max_value'} < 100) {
    do {
      ++$self->{'y_max_value'};
    } while ($self->{'y_max_value'} % 8 != 0);
  }
  else {
    #  First, get the # of digits in the y_max_value
    my $num_digits = 1 + int(log($self->{'y_max_value'}) / log(10));

    # We want to zero out all but the top 2 digits
    my $mask_length = $num_digits - 2;
    $self->{'y_max_value'} /= 10**$mask_length;
    $self->{'y_max_value'} = int($self->{'y_max_value'});
    $self->{'y_max_value'} *= 10**$mask_length;

    # Add 10^$mask_length to the max value
    # Continue to increase until it's divisible by 8 * 10^($mask_length-1)
    # (Throwing in the -1 keeps at least the smallest digit at zero)
    do {
      $self->{'y_max_value'} += 10**$mask_length;
    } while ($self->{'y_max_value'} % (8 * (10**($mask_length - 1))) != 0);
  }


  # Add the x-axis labels into the data structure
  my $date_progression = generateDateProgression($datefrom, $dateto);
  unshift(@data, $date_progression);

  if ($self->{'gt'}) {

    # Add Grand Total to label list
    push(@{$self->{'labels'}}, $self->{'labelgt'});

    $data[$gt_index] ||= [];
  }

  return \@data;
}

# Flatten the data structure into a list of series_ids
sub getSeriesIDs {
  my $self = shift;
  my @series_ids;

  foreach my $line (@{$self->{'lines'}}) {
    foreach my $series (@$line) {
      push(@series_ids, $series->{'series_id'});
    }
  }

  return @series_ids;
}

# Class method to get the data necessary to populate the "select series"
# widgets on various pages.
sub getVisibleSeries {
  my %cats;

  my $grouplist = Bugzilla->user->groups_as_string;

  # Get all visible series
  my $dbh      = Bugzilla->dbh;
  my $serieses = $dbh->selectall_arrayref(
    "SELECT cc1.name, cc2.name, "
      . "series.name, series.series_id "
      . "FROM series "
      . "INNER JOIN series_categories AS cc1 "
      . "    ON series.category = cc1.id "
      . "INNER JOIN series_categories AS cc2 "
      . "    ON series.subcategory = cc2.id "
      . "LEFT JOIN category_group_map AS cgm "
      . "    ON series.category = cgm.category_id "
      . "    AND cgm.group_id NOT IN($grouplist) "
      . "WHERE creator = ? OR (is_public = 1 AND cgm.category_id IS NULL) "
      . $dbh->sql_group_by(
      'series.series_id', 'cc1.name, cc2.name, ' . 'series.name'
      ),
    undef,
    Bugzilla->user->id
  );
  foreach my $series (@$serieses) {
    my ($cat, $subcat, $name, $series_id) = @$series;
    $cats{$cat}{$subcat}{$name} = $series_id;
  }

  return \%cats;
}

sub generateDateProgression {
  my ($datefrom, $dateto) = @_;
  my @progression;

  $dateto = $dateto || time();
  my $oneday = 60 * 60 * 24;

  # When the from and to dates are converted by str2time(), you end up with
  # a time figure representing midnight at the beginning of that day. We
  # adjust the times by 1/3 and 2/3 of a day respectively to prevent
  # edge conditions in time2str().
  $datefrom += $oneday / 3;
  $dateto   += (2 * $oneday) / 3;

  while ($datefrom < $dateto) {
    push(@progression, time2str("%Y-%m-%d", $datefrom));
    $datefrom += $oneday;
  }

  return \@progression;
}

sub dump {
  my $self = shift;

  # Make sure we've read in our data
  my $data = $self->data;

  require Data::Dumper;
  say "<pre>Bugzilla::Chart object:";
  print html_quote(Data::Dumper::Dumper($self));
  print "</pre>";
}

1;

=head1 B<Methods in need of POD>

=over

=item remove

=item add

=item dump

=item readData

=item getSeriesIDs

=item data

=item init

=item getVisibleSeries

=item generateDateProgression

=item sum

=back
