#!/usr/bin/perl

use strict;
use warnings;
use Linux::Inotify2;
use YAML::Tiny;
use Getopt::Long;
use File::stat;
use File::Find;
use File::Basename;
use File::Path qw(make_path);
use File::Copy qw(move);
use AnyEvent;
use RPM2;
use Time::HiRes 'time';
use Email::MIME;
use Email::Sender::Simple qw(sendmail);
use Email::Sender::Transport::Sendmail;
use Net::LDAP;

# Init an empty conf
my $conf = {};

# Disable output buffering
$| = 1;

# Defaults for command line flags
my $opt = {
  config  => '../etc/config.yml',
  verbose => 0,
  quiet   => 0
};

# Read some options from the command line
GetOptions (
  'config=s' => \$opt->{config},
  'quiet'    => \$opt->{quiet},
  'verbose'  => \$opt->{verbose}
);

# Check if the config file exists, and if so, parse it
# and load it in $conf
if ( -e $opt->{config} ) {
  log_verbose( "Reading config file " . $opt->{config} );
  my $yaml = YAML::Tiny->read( $opt->{config} );

  if ( not $yaml or not $yaml->[0] ) {
    die "Config file " . $opt->{config} . " is invalid\n";
  }
  $conf = $yaml->[0];
} else {
  # If the config file doesn't exist, just die
  die "Config file " . $opt->{config} . " doesn't exist\n";
}

my $ldap_msg;

my $inotify = new Linux::Inotify2
   or die "Unable to create new inotify object: $!";

log_verbose("Searching for folders in $conf->{paths}->{uploads}");
find({
  wanted => sub { -d and create_watcher($inotify, $File::Find::name); }
}, $conf->{paths}->{uploads});

my $cv = AnyEvent->condvar;

my $poller = AnyEvent->io(
  fh   => $inotify->fileno,
  poll => 'r',
  cb   => sub { $inotify->poll }
);

# Receive event signals (inotify signals)
$cv->recv;

# Print messages only if the verbose flag was given
sub log_verbose {
  my $msg = shift;
  print $msg . "\n" if ( $opt->{verbose} );
}

# Print normal messages
sub log_info {
  my $msg = shift;
  print $msg . "\n" if ( not $opt->{quiet} );
}

# Print error messages
sub log_error {
  my $msg = shift;
  print $msg . "\n";
}

# Create a watcher for a specific directory
sub create_watcher {
  my ($inotify, $dir) = @_;
  log_verbose("Start watching folder $dir");
  $inotify->watch ($dir, IN_CLOSE_WRITE | IN_MOVED_TO, sub {
    my $event = shift;
    my $candidate = $event->fullname;
    handle_submit($candidate);
  });
}

# takes the path of an SRPM to rebuild,
# build it with mock, sign the result, update the repo
# and sync to remote mirrors if defined
sub handle_submit {
  my $srpm = shift;
  if (not -f $srpm){
    log_verbose("$srpm isn't a file, ignoring");
    return;
  }
  if ($srpm !~ m/src\.rpm$/i){
    log_verbose("New file $srpm isn't an src.rpm file, ignoring");
    return;
  }
  log_info("New file to process $srpm");
  my $submiter = getpwuid(stat($srpm)->uid);
  my $email;
  log_info("File submited by $submiter");
  my $ldap = ldap_connect();
  if (defined $ldap){
    $email = user2email($ldap, $submiter);
    if (not defined $email){
      log_verbose("LDAP returned no result");
    }
  }
  if (defined $email){
    log_verbose("Notifications will be sent to $email");
  } else {
    log_verbose("No email address for $submiter, no notification will be sent");
  }
  $ldap->done;
  $ldap->disconnect;
  # Do not check the signature here
  # We could try to submit a signed src.rpm for which we do not have the key system-wide
  my $src_pkg = RPM2->open_package($srpm, RPM2->_rpmvsf_nosignatures);
  if (not $src_pkg->is_source_package){
    log_verbose("Couldn't parse $srpm as a valid srpm");
    return;
  }
  my $target = basename(dirname($srpm));
  if (not defined $conf->{targets}->{$target}){
    log_info("$srpm submited for target $target, but it's not defined in the configuration");
  }
  foreach my $arch (@{$conf->{targets}->{$target}}){
    my $job_id = $src_pkg->as_nvre() . '-' . time();
    my $result = $conf->{paths}->{builds} . '/' . $submiter . '/' . $target . '-' . $arch . '/' . $job_id;
    log_info("Rebuilding $srpm for $target/$arch in $result (job ID $job_id)");
    make_path($result);
    my $mock_msg;
    foreach my $out (qx(mock -r $target-$arch --resultdir=$result $srpm 2>&1)){
      $mock_msg .= $out;
      chomp $out;
      log_info("[$job_id] $out");
    }
    if ($? != 0) {
      log_info("[$job_id] Build submited by $submiter failed");
      handle_error($job_id, 'Mock build', $mock_msg, $email);
      return;
    }
    my $repo_dir = $conf->{paths}->{repo};
    my $repo_cache_dir = $conf->{paths}->{repo_cache};
    if ($src_pkg->release =~ m/\.(beta|git\.)/){
      $repo_dir .= '/testing';
      $repo_cache_dir .= '/testing';
    }
    $repo_dir .= '/' . $target;
    $repo_cache_dir .= '/' . $target;
    find({
      wanted => sub {
        return if (not -f);
        return if (not $_ =~ m/\.rpm$/);
        my $built_pkg = $_;
        log_info("[$job_id] Signing package $built_pkg");
        # Note : the optional passphrase for the gpg key is in rpmmacros
        qx(rpm --addsign $built_pkg);
        if ($? != 0) {
          log_info("[$job_id] Signing failed");
          handle_error($job_id, 'Package signature error', "Command rpm --addsign $built_pkg failed", $email);
          return;
        }
        # Open the package without checking the signature, as the key might not be present in the
        # rpm trusted store
        my $pkg = RPM2->open_package($built_pkg, RPM2->_rpmvsf_nosignatures);
        my $dest = $repo_dir;
        if ($pkg->is_source_package){
          $dest .= '/SRPMS';
        } else {
          # the resulting RPM can be noarch, so use this instead of $arch
          $dest .= '/' . $pkg->arch;
        }
        log_info("[$job_id] Moving $built_pkg to the repo $dest");
        make_path($dest);
        make_path($repo_cache_dir);
        move $built_pkg, $dest . '/' . basename($built_pkg);
      }
    }, $result);
    log_info("[$job_id] Removing old packages");
    qx(rm -f \$(repomanage --keep=2 --old $repo_dir));
    log_info("[$job_id] Updating repo metadata for $target");
    qx(createrepo --checksum sha -x "*debuginfo*" --update -c $repo_cache_dir $repo_dir);
    if ($? != 0) {
      log_info("[$job_id] Createrepo failed");
      handle_error(
        $job_id,
        'Createrepo error',
        "Command createrepo --checksum sha -x \"*debuginfo*\" --update -c $repo_cache_dir $repo_dir",
	$email
      );
      return;
    }
    log_info("[$job_id] Building package finished");
    # Now push to mirrors if defined
    if (defined $conf->{mirror} and defined $conf->{mirror}->{push}){
      foreach my $mirror (@{$conf->{mirror}->{push}}){
        log_info("[$job_id] syncing repo to $mirror->{dest}");
        my $sync_cmd;
        if (defined $mirror->{sync_cmd}){
          $sync_cmd = $mirror->{sync_cmd};
        } else {
          $sync_cmd = 'rsync ';
          if (defined $mirror->{rsync_opts}){
            $sync_cmd .= join(' ', @{$mirror->{rsync_opts}});
          } else {
            $sync_cmd .= join(' ', @{$conf->{mirror}->{rsync_opts}});
          }
          $sync_cmd .= ' ' . $conf->{paths}->{repo} . '/ ' . $mirror->{dest} . '/';
        }
        log_verbose("[$job_id] Running command $sync_cmd");
        foreach my $out (qx($sync_cmd 2>&1)){
          chomp $out;
          log_verbose("[$job_id] $out");
        }
        if ($? != 0) {
          log_info("[$job_id] Syncing to $mirror->{dest} failed");
          handle_error($job_id, 'Mirror update error', "Command $sync_cmd failed", $email);
          return;
        }
      }
    }
    if (defined $email){
      my $body = "Resulting RPM are available in $conf->{paths}->{repo}/$target";
      if (defined $conf->{mirror} and defined $conf->{mirror}->{push}){
        $body .= "\nand have been synced to the following mirror:\n";
        foreach my $mirror (@{$conf->{mirror}->{push}}){
          $body .= "$mirror->{dest}\n";
        }
      }
      send_notification(
        $email,
        "Rebuilding " . $src_pkg->as_nvre() . " for $target/$arch succeded",
        $body
      );
    }
  }
  if (defined $ldap){
    $ldap->done;
    $ldap->disconnect;
  }
  return;
}

# Handle errors. Log it, and notify the admin
sub handle_error {
  my $job_id = shift;
  my $step   = shift;
  my $err    = shift;
  my $dest   = shift;

  log_error( $err );
  if ( defined $dest ) {
    send_notification(
      $dest,
      "Error while building $job_id",
      "Building $job_id failed at step '$step'. The error was\n$err\n"
    );
  }
}

# Send an email message
sub send_notification {
  my $to = shift;
  my $subject = shift;
  my $body = shift;
  my $mail   = Email::MIME->create(  
    header_str => [
      From    => $conf->{notify}->{from},
      To      => $to,
      Subject => $subject
    ],
    attributes => {
      charset  => 'utf-8',
      encoding => 'base64'
    },
    body_str => $body
  );
  my $transport = Email::Sender::Transport::Sendmail->new();
  sendmail( $mail, { transport => $transport } );
}

# Lookup in LDAP if we can get the email address of a user
sub user2email {
  my $ldap = shift;
  my $user = shift;
  if (not defined $ldap or not defined $conf->{ldap}->{search_base} or not defined $conf->{ldap}->{search_filter}){
    log_verbose("LDAP not connected or not configured, skiping lookup");
    return;
  }
  my $filter = $conf->{ldap}->{search_filter};
  $filter =~ s/\{user\}/$user/g;
  log_verbose("Searching in $conf->{ldap}->{search_base} with filter $filter");
  my $results = $ldap->search(
    base   => $conf->{ldap}->{search_base},
    filter => $filter,
    attrs  => [ $conf->{ldap}->{email_attr} ]
  );
  if ($results->code){
    log_verbose("Error occured while searching in LDAP : " . $results->error);
    return;
  }
  if ($results->count != 1){
    log_verbose("Searching returned " . $results->count . "result(s), while it should have returned 1");
    return;
  }
  return $results->entry(0)->get_value( $conf->{ldap}->{email_attr} );
}

# Connect to LDAP
# which will be used to lookup the email address of the submiter
sub ldap_connect {
  my $ldaph;
  if (defined $conf->{ldap} and defined $conf->{ldap}->{servers}){
    log_verbose("Connecting to " . join(', ', @{$conf->{ldap}->{servers}}));
    $ldaph = new Net::LDAP($conf->{ldap}->{servers},
      timeout => 10,
    );
    if (not defined $ldaph){
      log_info("Couldn't connect to any LDAP servers (" . join(',', @{$conf->{ldap}->{servers}}) . ")");
    } else {
      if (defined $conf->{ldap}->{start_tls} and $conf->{ldap}->{start_tls}){
        log_verbose("Upgrade LDAP connection using StartTLS");
        $ldap_msg = $ldaph->start_tls(
          verify => 'require'
        );
        if ($ldap_msg->code){
          log_verbose("StartTLS failed : " . $ldap_msg->error);
          log_verbose("LDAP support will be disabled");
          $ldaph = undef;
        }
      }
      if (defined $conf->{ldap}->{bind_dn} and defined $conf->{ldap}->{bind_pass}){
        log_verbose("Binding as $conf->{ldap}->{bind_dn}");
        $ldap_msg = $ldaph->bind(
          $conf->{ldap}->{bind_dn},
          password => $conf->{ldap}->{bind_pass}
        );
        if ($ldap_msg->code){
          log_verbose("LDAP bind failed : " . $ldap_msg->error);
          log_verbose("LDAP support will be disabled");
          $ldaph = undef;
        }
      } else {
        log_verbose("Using anonymous bind");
        $ldap_msg = $ldaph->bind;
      }
    }
  } else {
    log_verbose("No LDAP servers configured");
  }
  return $ldaph;
}
