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

package Bugzilla::Extension::Push::Connector::ServiceNow;

use 5.10.1;
use strict;
use warnings;

use base 'Bugzilla::Extension::Push::Connector::Base';

use Bugzilla::Attachment;
use Bugzilla::Bug;
use Bugzilla::Component;
use Bugzilla::Constants;
use Bugzilla::Extension::Push::Constants;
use Bugzilla::Extension::Push::Serialize;
use Bugzilla::Extension::Push::Util;
use Bugzilla::Field;
use Bugzilla::Mailer;
use Bugzilla::Product;
use Bugzilla::User;
use Bugzilla::Util qw(trim);
use Email::MIME;
use FileHandle;
use LWP;
use MIME::Base64;
use Net::LDAP;

use constant SEND_COMPONENTS => (
  {product => 'mozilla.org', component => 'Server Operations: Desktop Issues',},
);

sub options {
  return (
    {
      name     => 'bugzilla_user',
      label    => 'Bugzilla Service-Now User',
      type     => 'string',
      default  => 'service.now@bugzilla.tld',
      required => 1,
      validate => sub {
        Bugzilla::User->new({name => $_[0]}) || die "Invalid Bugzilla user ($_[0])\n";
      },
    },
    {
      name     => 'ldap_scheme',
      label    => 'Mozilla LDAP Scheme',
      type     => 'select',
      values   => ['LDAP', 'LDAPS'],
      default  => 'LDAPS',
      required => 1,
    },
    {
      name     => 'ldap_host',
      label    => 'Mozilla LDAP Host',
      type     => 'string',
      default  => '',
      required => 1,
    },
    {
      name     => 'ldap_user',
      label    => 'Mozilla LDAP Bind Username',
      type     => 'string',
      default  => '',
      required => 1,
    },
    {
      name     => 'ldap_pass',
      label    => 'Mozilla LDAP Password',
      type     => 'password',
      default  => '',
      required => 1,
    },
    {
      name     => 'ldap_poll',
      label    => 'Mozilla LDAP Poll Frequency',
      type     => 'string',
      default  => '3',
      required => 1,
      help     => 'minutes',
      validate => sub {
        $_[0] =~ /\D/ && die "LDAP Poll Frequency must be an integer\n";
        $_[0] == 0 && die "LDAP Poll Frequency cannot be less than one minute\n";
      },
    },
    {
      name     => 'service_now_url',
      label    => 'Service Now JSON URL',
      type     => 'string',
      default  => 'https://mozilladev.service-now.com',
      required => 1,
      help     => "Must start with https:// and end with ?JSON",
      validate => sub {
        $_[0] =~ m#^https://[^\.\/]+\.service-now\.com\/#
          || die "Invalid Service Now JSON URL\n";
        $_[0] =~ m#\?JSON$#
          || die "Invalid Service Now JSON URL (must end with ?JSON)\n";
      },
    },
    {
      name     => 'service_now_user',
      label    => 'Service Now JSON Username',
      type     => 'string',
      default  => '',
      required => 1,
    },
    {
      name     => 'service_now_pass',
      label    => 'Service Now JSON Password',
      type     => 'password',
      default  => '',
      required => 1,
    },
  );
}

sub options_validate {
  my ($self, $config) = @_;
  my $host = $config->{ldap_host};
  my $scheme = lc($config->{ldap_scheme});
  eval {
    my $ldap
      = Net::LDAP->new($host, scheme => $scheme, onerror => 'die', timeout => 5)
      or die $!;
    $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass});
  };
  if ($@) {
    die sprintf("Failed to connect to %s://%s/: %s\n", $scheme, $host, $@);
  }
}

my $_instance;

sub init {
  my ($self) = @_;
  $_instance = $self;
}

sub load_config {
  my ($self) = @_;
  $self->SUPER::load_config(@_);
  $self->{bugzilla_user}
    ||= Bugzilla::User->new({name => $self->config->{bugzilla_user}});
}

sub should_send {
  my ($self, $message) = @_;

  my $data = $message->payload_decoded;
  my $bug_data = $self->_get_bug_data($data) || return 0;

  # we don't want to send the initial comment in a separate message
  # because we inject it into the initial message
  if (exists $data->{comment} && $data->{comment}->{number} == 0) {
    return 0;
  }

  my $target = $data->{event}->{target};
  unless ($target eq 'bug' || $target eq 'comment' || $target eq 'attachment') {
    return 0;
  }

  # ensure the service-now user can see the bug
  if (!$self->{bugzilla_user} || !$self->{bugzilla_user}->is_enabled) {
    return 0;
  }
  $self->{bugzilla_user}->can_see_bug($bug_data->{id}) || return 0;

  # don't push changes made by the service-now account
  $data->{event}->{user}->{id} == $self->{bugzilla_user}->id && return 0;

  # filter based on the component
  my $bug  = Bugzilla::Bug->new($bug_data->{id});
  my $send = 0;
  foreach my $rh (SEND_COMPONENTS) {
    if ($bug->product eq $rh->{product} && $bug->component eq $rh->{component}) {
      $send = 1;
      last;
    }
  }
  return $send;
}

sub send {
  my ($self, $message) = @_;
  my $logger = Bugzilla->push_ext->logger;
  my $config = $self->config;

# should_send initializes bugzilla_user; make sure we return a useful error message
  if (!$self->{bugzilla_user}) {
    return (PUSH_RESULT_TRANSIENT,
      "Invalid Bugzilla-user (" . $self->config->{bugzilla_user} . ")");
  }

  # load the bug
  my $data     = $message->payload_decoded;
  my $bug_data = $self->_get_bug_data($data);
  my $bug      = Bugzilla::Bug->new($bug_data->{id});

  if ($message->routing_key eq 'bug.create') {

    # inject the comment into the data for new bugs
    my $comment = shift @{$bug->comments};
    if ($comment->body ne '') {
      $bug_data->{comment}
        = Bugzilla::Extension::Push::Serialize->instance->object_to_hash($comment, 1);
    }

  }
  elsif ($message->routing_key eq 'attachment.create') {

    # inject the attachment payload
    my $attachment = Bugzilla::Attachment->new($data->{attachment}->{id});
    $data->{attachment}->{data} = encode_base64($attachment->data);
  }

  # map BMO login to ldap login and insert into JSON payload
  $self->_add_ldap_logins($data, {});

  # flatten JSON data
  $self->_flatten($data);

  # add sysparm_action
  $data->{sysparm_action} = 'insert';

  if ($logger->debugging) {
    $logger->debug(to_json(ref($data) ? $data : from_json($data), 1));
  }

  # send to service-now
  my $request = HTTP::Request->new(POST => $self->config->{service_now_url});
  $request->content_type('application/json');
  $request->content(to_json($data));
  $request->authorization_basic($self->config->{service_now_user},
    $self->config->{service_now_pass});

  $self->{lwp} ||= LWP::UserAgent->new(agent => Bugzilla->localconfig->urlbase);
  my $result = $self->{lwp}->request($request);

  # HTTP level errors
  if (!$result->is_success) {

    # treat these as transient
    return (PUSH_RESULT_TRANSIENT, $result->status_line);
  }

  # empty response
  if (length($result->content) == 0) {

    # malformed request, treat as transient to allow code to fix
    # may also be misconfiguration on servicenow, also transient
    return (PUSH_RESULT_TRANSIENT, "Empty response");
  }

  # JSON errors
  my $result_data;
  eval { $result_data = from_json($result->content); };
  if ($@) {
    return (PUSH_RESULT_TRANSIENT, clean_error($@));
  }
  if ($logger->debugging) {
    $logger->debug(to_json($result_data, 1));
  }
  if (exists $result_data->{error}) {
    return (PUSH_RESULT_ERROR, $result_data->{error});
  }

  # malformed/unexpected JSON response
  if (!exists $result_data->{records}
    || ref($result_data->{records}) ne 'ARRAY'
    || scalar(@{$result_data->{records}}) == 0)
  {
    return (PUSH_RESULT_ERROR,
      "Malformed JSON response from ServiceNow: missing or empty 'records' array");
  }

  my $record = $result_data->{records}->[0];
  if (ref($record) ne 'HASH') {
    return (PUSH_RESULT_ERROR,
      "Malformed JSON response from ServiceNow: 'records' array does not contain an object"
    );
  }

  # sys_id is the unique identifier for this action
  if (!exists $record->{sys_id} || $record->{sys_id} eq '') {
    return (PUSH_RESULT_ERROR,
      "Malformed JSON response from ServiceNow: 'records object' does not contain a valid sys_id"
    );
  }

  # success
  return (PUSH_RESULT_OK, "sys_id: " . $record->{sys_id});
}

sub _get_bug_data {
  my ($self, $data) = @_;
  my $target = $data->{event}->{target};
  if ($target eq 'bug') {
    return $data->{bug};
  }
  elsif (exists $data->{$target}->{bug}) {
    return $data->{$target}->{bug};
  }
  else {
    return;
  }
}

sub _flatten {

  # service-now expects a flat JSON object
  my ($self, $data) = @_;

  my $target = $data->{event}->{target};

  # delete unnecessary deep objects
  if ($target eq 'comment' || $target eq 'attachment') {
    $data->{$target}->{bug_id} = $data->{$target}->{bug}->{id};
    delete $data->{$target}->{bug};
  }
  delete $data->{event}->{changes};

  $self->_flatten_hash($data, $data, 'u');
}

sub _flatten_hash {
  my ($self, $base_hash, $hash, $prefix) = @_;
  foreach my $key (keys %$hash) {
    if (ref($hash->{$key}) eq 'HASH') {
      $self->_flatten_hash($base_hash, $hash->{$key}, $prefix . "_$key");
    }
    elsif (ref($hash->{$key}) ne 'ARRAY') {
      $base_hash->{$prefix . "_$key"} = $hash->{$key};
    }
    delete $hash->{$key};
  }
}

sub _add_ldap_logins {
  my ($self, $rh, $cache) = @_;
  if (exists $rh->{login}) {
    my $login = $rh->{login};
    $cache->{$login} ||= $self->_bmo_to_ldap($login);
    Bugzilla->push_ext->logger->debug(
      "BMO($login) --> LDAP(" . $cache->{$login} . ")");
    $rh->{ldap} = $cache->{$login};
  }
  foreach my $key (keys %$rh) {
    next unless ref($rh->{$key}) eq 'HASH';
    $self->_add_ldap_logins($rh->{$key}, $cache);
  }
}

sub _bmo_to_ldap {
  my ($self, $login) = @_;
  my $ldap = $self->_ldap_cache();

  return '' unless $login =~ /\@mozilla\.(?:com|org)$/;

  foreach my $check ($login, canon_email($login)) {

    # check for matching bugmail entry
    foreach my $mail (keys %$ldap) {
      next unless $ldap->{$mail}{bugmail_canon} eq $check;
      return $mail;
    }

    # check for matching mail
    if (exists $ldap->{$check}) {
      return $check;
    }

    # check for matching email alias
    foreach my $mail (sort keys %$ldap) {
      next unless grep { $check eq $_ } @{$ldap->{$mail}{aliases}};
      return $mail;
    }
  }

  return '';
}

sub _ldap_cache {
  my ($self) = @_;
  my $logger = Bugzilla->push_ext->logger;
  my $config = $self->config;

  # cache of all ldap entries; updated infrequently
  if (!$self->{ldap_cache_time}
    || (time) - $self->{ldap_cache_time} > $config->{ldap_poll} * 60)
  {
    $logger->debug('refreshing LDAP cache');

    my $cache = {};

    my $host = $config->{ldap_host};
    my $scheme = lc($config->{ldap_scheme});
    my $ldap = Net::LDAP->new($host, scheme => $scheme, onerror => 'die') or die $!;
    $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass});
    foreach my $ldap_base ('o=com,dc=mozilla', 'o=org,dc=mozilla') {
      my $result = $ldap->search(
        base   => $ldap_base,
        scope  => 'sub',
        filter => '(mail=*)',
        attrs  => ['mail', 'bugzillaEmail', 'emailAlias', 'cn', 'employeeType'],
      );
      foreach my $entry ($result->entries) {
        my ($name, $bugMail, $mail, $type)
          = map { $entry->get_value($_) || '' } qw(cn bugzillaEmail mail employeeType);
        next if $type eq 'DISABLED';
        $mail    = lc $mail;
        $bugMail = '' if $bugMail !~ /\@/;
        $bugMail = trim($bugMail);
        if ($bugMail =~ / /) {
          $bugMail = (grep {/\@/} split / /, $bugMail)[0];
        }
        $name =~ s/\s+/ /g;
        $cache->{$mail}{name}          = trim($name);
        $cache->{$mail}{bugmail}       = $bugMail;
        $cache->{$mail}{bugmail_canon} = canon_email($bugMail);
        $cache->{$mail}{aliases}       = [];
        foreach my $alias (@{$entry->get_value('emailAlias', asref => 1) || []}) {
          push @{$cache->{$mail}{aliases}}, canon_email($alias);
        }
      }
    }

    $self->{ldap_cache}      = $cache;
    $self->{ldap_cache_time} = (time);
  }

  return $self->{ldap_cache};
}

1;

