#!/usr/bin/perl
# Copyright (C) 2008-2012 eBox Technologies S.L.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License, version 2, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

use strict;
use warnings;

use EBox;
use EBox::Global;
use EBox::Ldap;
use EBox::Config;
use EBox::UsersAndGroups;
use EBox::Util::Lock;
use EBox::Exceptions::InvalidData;
use Error qw(:try);
use Perl6::Junction qw(any);
use File::Slurp;

use Net::LDAP::Control::Paged;
use Net::LDAP::Constant qw( LDAP_CONTROL_PAGED );

use constant IGNORED_USERS  => EBox::Config::etc() . 'ad-sync_ignore.users';
use constant IGNORED_GROUPS => EBox::Config::etc() . 'ad-sync_ignore.groups';
use constant LOCK_NAME      => 'zentyal-adsync';
use constant MAX_LDAP_PAGES => 1000;
use constant  MAX_LDAP_ATTRIBUTE_VALUES => 1500;

EBox::init();
EBox::debug("ad-sync started");

my $usersMod = EBox::Global->getInstance()->modInstance('users');
my $mode = $usersMod->model('Mode');
my $host = $mode->remoteValue();
my $settings = $usersMod->model('ADSyncSettings');
my $username = $settings->usernameValue();
my $password = $settings->adpassValue();

my $ldapAD;
try {
    $ldapAD = EBox::Ldap::safeConnect($host);
} catch Error with {
    EBox::error("[ad-sync] Can't connect to $host.");
    exit 1;
};

my $base = EBox::Config::configkey('adsync_dn');

$base = baseDnAD() unless $base;

my $bindDn;
if (EBox::UsersAndGroups::_checkName($username)) {
    $bindDn = "CN=$username,CN=Users,$base";
} else {
    $bindDn = $username;
}

try {
    EBox::Ldap::safeBind($ldapAD, $bindDn, $password);
} catch Error with {
    EBox::error("[ad-sync] Can't bind to $host as '$bindDn'.");
    exit 1;
};

EBox::Util::Lock::lock(LOCK_NAME);

my %newUsers = usersAD($ldapAD);
my %newGroups = groupsAD($ldapAD);

my %currentUsers = map { $_->{username} => $_ } $usersMod->users();
my %currentGroups = map { $_->{account} => $_ } $usersMod->groups();

my @usersToAdd  = grep { not exists $currentUsers{$_} } keys %newUsers;
my @usersToDel  = grep { not exists $newUsers{$_} } keys %currentUsers;
my @usersToModify = grep { exists $newUsers{$_} } keys %currentUsers;

my @groupsToAdd  = grep { not exists $currentGroups{$_} } keys %newGroups;
my @groupsToDel  = grep { not exists $newGroups{$_} } keys %currentGroups;
my @groupsToModify = grep { exists $newGroups{$_} } keys %currentGroups;

foreach my $username (@usersToDel) {
    EBox::debug("[ad-sync] Deleting user '$username' that no longer exists.");

    try {
        $usersMod->delUser($username);
    } catch Error with {
        EBox::warn("[ad-sync] Error deleting user '$username'.");
    };
}

my %usersToIgnore;
try {
    my @ignoredUsers = read_file(IGNORED_USERS);
    chomp (@ignoredUsers);
    foreach my $username (@ignoredUsers) {
        $usersToIgnore{$username} = 1;
        next unless $usersMod->userExists($username);
        EBox::debug("[ad-sync] Wiping user '$username' (that is ignored).");

        try {
            $usersMod->delUser($username);
        } otherwise {
            EBox::warn("[ad-sync] Error deleting user '$username'.");
        };
        $usersToIgnore{''} = 1; # ignore empty user
    }
} otherwise {
    EBox::error("[ad-sync] Can't open " . IGNORED_USERS . ": $!");
};

foreach my $username (@usersToAdd) {
    my $user = $newUsers{$username};

    if (exists $usersToIgnore{$username}) {
        EBox::debug("[ad-sync] Skipping new user '$username' (ignored).");
        next;
    }

    EBox::debug("[ad-sync] Adding new user '$username'.");

    # The user must have a initial password in order to add it, as
    # we still don't have the good one, we generate a random one.
    $user->{password} = randomPassword();

    try {
        $usersMod->addUser($user);
    } catch EBox::Exceptions::InvalidData with {
        EBox::warn("[ad-sync] Can't add user '$username' because it contains invalid characters.");
    } catch Error with {
        EBox::warn("[ad-sync] Error adding user '$username'.");
    };
}

foreach my $username (@usersToModify) {
    my $user = $newUsers{$username};

    try {
        modifyUserIfChanged($user);
    } catch Error with {
        EBox::warn("[ad-sync] Error modifying user '$username'.");
    };
}

foreach my $groupname (@groupsToDel) {
    EBox::debug("[ad-sync] Deleting group '$groupname' that no longer exists.");

    try {
        $usersMod->delGroup($groupname);
    } catch Error with {
        EBox::warn("[ad-sync] Error deleting group '$groupname'.");
    };
}

my %groupsToIgnore;
try {
    my @ignoredGroups = read_file(IGNORED_GROUPS);
    chomp (@ignoredGroups);
    foreach my $groupname (@ignoredGroups) {
        $groupsToIgnore{$groupname} = 1;
        next unless $usersMod->groupExists($groupname);
        EBox::debug("[ad-sync] Wiping group '$groupname' (that is ignored).");

        try {
            $usersMod->delGroup($groupname);
        } otherwise {
            EBox::warn("[ad-sync] Error deleting group '$groupname'.");
        };
    }
} otherwise {
    EBox::error("[ad-sync] Can't open " . IGNORED_GROUPS . ": $!");
};

foreach my $groupname (@groupsToAdd) {
    my $group = $newGroups{$groupname};

    if (exists $groupsToIgnore{$groupname}) {
        EBox::debug("[ad-sync] Skipping new group '$groupname' (ignored).");
        next;
    }

    EBox::debug("[ad-sync] Adding new group '$groupname'.");

    try {
        $usersMod->addGroup($groupname, $group->{comment}, 0);
    } catch Error with {
        EBox::warn("[ad-sync] Error adding group '$groupname'.");
    };

    unless ($usersMod->groupExists($groupname)) {
        EBox::debug("[ad-sync] Skipping adding users to ignored group '$groupname'.");
        next;
    }

    foreach my $member (@{$group->{members}}) {
        my $user = getPrincipalName($ldapAD, $member);
        next unless $user;

        unless ($usersMod->userExists($user)) {
            EBox::debug("[ad-sync] Skipping not existing user '$user' (probably ignored).");
            next;
        }
        if ($user eq any @{$usersMod->usersInGroup($groupname)}) {
            EBox::debug("[ad-sync] Skipping user '$user' already on group '$groupname'.");
        } else {
            EBox::debug("[ad-sync] Adding user '$user' to new group '$groupname'.");
            try {
                $usersMod->addUserToGroup($user, $groupname);
            } catch Error with {
                EBox::warn("[ad-sync] Can't add user '$user' to group '$groupname'.");
            };
        }
    }
}

foreach my $groupname (@groupsToModify) {
    my $group = $newGroups{$groupname};
    my $usersInGroup = $usersMod->usersInGroup($groupname);
    my %newMembers = map { 
        my $principalName = getPrincipalName($ldapAD, $_);
        if ($principalName) {
            ($principalName => 1)
        } else {
            ()
        }
    }   @{$group->{members}};
    my %currentMembers = map { $_ => 1 } @{$usersInGroup};
    my @membersToAdd = grep { not exists $currentMembers{$_} } keys %newMembers;
    my @membersToDel = grep { not exists $newMembers{$_} } keys %currentMembers;

    foreach my $member (@membersToAdd) {
        # skip machine accounts
        if ($member =~ /\$$/) {
            next;
        }
        unless ($usersMod->userExists($member)) {
            EBox::debug("[ad-sync] Skipping not existing user '$member' (probably ignored).");
            next;
        }
        try {
            next unless $member;
            EBox::debug("[ad-sync] Adding user $member to existing group '$groupname'");
            $usersMod->addUserToGroup($member, $groupname);
        } catch Error with {
            EBox::warn("[ad-sync] can't add user $member to group '$groupname'.");
        };
    }

    foreach my $member (@membersToDel) {
        try {
            next unless $member;
            EBox::debug("[ad-sync] Deleting user $member from group '$groupname'");
            $usersMod->delUserFromGroup($member, $groupname);
        } catch Error with {
            EBox::warn("[ad-sync] can't del user $member from group '$groupname'.");
        };
    }

    my $comment = $group->{comment};
    modifyGroupIfChanged({ groupname => $groupname, comment => $comment});
}

EBox::Util::Lock::unlock(LOCK_NAME);
EBox::debug("ad-sync finished");

# FIXME: This code is duplicated, write it in a single point
sub randomPassword
{
    my $pass = '';
    my $letters = 'abcdefghijklmnopqrstuvwxyz';
    my @chars= split(//, $letters . uc($letters) .
            '-+/.0123456789');
    for my $i (1..16) {
        $pass .= $chars[int(rand (scalar(@chars)))];
    }
    return $pass;
}

sub modifyUserIfChanged
{
    my ($user) = @_;

    my $currentInfo = $currentUsers{ $user->{username} };

    # Modify only if any of the fields are different
    if (($user->{givenname} ne $currentInfo->{givenname}) or
        ($user->{surname} ne $currentInfo->{surname}) or
        ($user->{comment} ne $currentInfo->{comment})) {
        EBox::debug("[ad-sync] Updating existing user '$username'.");
        $usersMod->modifyUser($user);
    }
}

sub modifyGroupIfChanged
{
    my ($group) = @_;

    my $groupname = $group->{groupname};
    my $currentInfo = $currentGroups{$groupname};

    # Modify only if the only modificable attribute is different
    if ((defined $currentInfo->{comment}) and ($group->{comment} ne $currentInfo->{comment})) {
        EBox::debug("[ad-sync] Updating existing group '$groupname'.");
        $usersMod->modifyGroup($group);
    }
}

# -- Active Directory helper functions --

sub baseDnAD {
    my $result = $ldapAD->search(
        'base' => '',
        'scope' => 'base',
        'filter' => '(objectclass=*)',
        'attrs' => ['namingContexts']
    );
    my $entry = ($result->entries)[0];
    my $attr = ($entry->attributes)[0];
    return $entry->get_value($attr);
}

sub usersAD
{
    my ($ldap) = @_;
    my %users;

    my $page = Net::LDAP::Control::Paged->new(size => MAX_LDAP_PAGES) or
        die "Error: the LDAP controller doesn't allow a page size of " . MAX_LDAP_PAGES;
    my %args = (
                base => $base,
                filter => '(&(objectclass=user)(!(objectclass=computer)))',
                scope => 'sub',
                attrs => ['userPrincipalName', 'sAMAccountName', 'cn', 'givenName', 'sn', 'description'],
                control => [$page],
               );

    my $cookie;
    my $nPage = 1;
    while (1) {
        my $result = $ldap->search(%args);

        foreach my $user ($result->sorted('userPrincipalName')) {
            my $username = $user->get_value('userPrincipalName');
            unless ($username) {
                $username = $user->get_value('sAMAccountName');
            }
            my $info = userInfoAD($ldap, $username, $user);
            $users{$info->{user}} = $info;
        }

        # check if we need  to search again to get the next page
        my ($resp) = $result->control(LDAP_CONTROL_PAGED) or last;
        $cookie = $resp->cookie or last;
        $page->cookie($cookie);

        $nPage += 1;
        if ($nPage > MAX_LDAP_PAGES) {
            EBox::debug("No more than " . MAX_LDAP_PAGES . " LDAP pages allowed");
            last;
        }
    }

    if ($cookie) {
        $page->cookie($cookie);
        $page->size(0);
        $ldap->search( %args );
    }

    EBox::debug((scalar keys %users) . ' users from ' . $nPage . ' pages ');
    return %users;
}

sub userInfoAD 
{
    my ($ldap, $user, $entry) = @_;

    # If $entry is undef we make a search to get the object, otherwise
    # we already have the entry
    unless ($entry) {
        my %args = (
                    base => $base,
                    filter => "(&(objectclass=user)(!(objectclass=computer))(|(userPrincipalName=$user)(sAMAccountName=$user)))",
                    scope => 'one',
                    attrs => ['*'],
                   );

        my $result = $ldap->search(%args);
        $entry = $result->entry(0);
    }

    my $username = $entry->get_value('userPrincipalName');
    unless ($username) {
        $username = $entry->get_value('sAMAccountName');
    }
    # Remove the domain part
    $username =~ s/@.*$//;

    my $cn = $entry->get_value('cn');
    my $surname = $entry->get_value('sn');
    my $givenName = $entry->get_value('givenName');
    my $comment = $entry->get_value('description');

    # Surname is optional in AD but mandatory in
    # the eBox LDAP, so if it not exits we must
    # fill it with other data.
    unless ($surname) {
        if ($givenName) {
            $surname = $givenName;
            $givenName = '';
        } else {
            $surname = $cn;
        }
    }

    # Mandatory data, some functions
    # require user and others username
    # so we include both
    my $userinfo = {
                    user => $username,
                    username => $username,
                    fullname => $cn,
                    surname => $surname,
                   };

    # Optional Data
    if ($givenName) {
        $userinfo->{'givenname'} = $givenName;
    } else {
        $userinfo->{'givenname'} = '';
    }
    if ($comment) {
        $userinfo->{'comment'} = $comment;
    } else {
        $userinfo->{'comment'} = '';
    }

    return $userinfo;
}

sub groupsAD
{
    my ($ldap) = @_;
    my %groups;

    my $page = Net::LDAP::Control::Paged->new(size => MAX_LDAP_PAGES) or
        die "Error: the LDAP controller doesn't allow a page size of " . MAX_LDAP_PAGES;
    my %args = (
                base => $base,
                filter => '(objectclass=group)',
                scope => 'sub',
                attrs => ['cn', 'member', 'mail'],
                control => [$page],
               );

    my $cookie;
    my $nPage = 1;
    while (1) {
        my $result = $ldap->search(%args);

        foreach my $entry ($result->sorted('cn')) {
            my $cn = $entry->get_value('cn');
            my $desc = $entry->get_value('description');
            my $mail = $entry->get_value('mail');
            
            my @members;
            # get members
            my $startRange = 0;
            my $increment  = MAX_LDAP_ATTRIBUTE_VALUES - 1;
            $args{filter} = "(&(objectclass=group)(cn=$cn))";

            my $membersLeft = 1;
            while ($membersLeft) {
                my $endRange = $startRange + $increment;
                my $memberAttr = "member;range=$startRange-$endRange";
                $args{attrs} = [ $memberAttr ];

                my $membResult = $ldapAD->search(%args);
                if ($membResult->count() == 0) {
                    last;
                }

                foreach my $entry ($membResult->entries()) {
                    # we should fetch the attr name because if it is the last
                    # the endRange will breplaced with '*'
                    my ($resultMemberAttr) = $entry->attributes();
                    if (defined $resultMemberAttr) {
                        $membersLeft =  not ($resultMemberAttr =~ m/\*$/);
                        push @members, $entry->get_value($resultMemberAttr);
                      } else {
                          $membersLeft = 0;
                     }
                }

                $startRange = $endRange + 1;
            }

            my $info = {
                'account' => $cn,
                'members' => \@members,
                'comment' => $desc,
                'mail' => $mail,
            };

            $groups{$cn} = $info;
        }

        # check if we need  to search again to get the next page
        my ($resp) = $result->control(LDAP_CONTROL_PAGED) or last;
        $cookie = $resp->cookie or last;
        $page->cookie($cookie);

        $nPage += 1;
        if ($nPage > MAX_LDAP_PAGES) {
            EBox::debug("No more than " . MAX_LDAP_PAGES . " LDAP pages allowed");
            last;
        }
    }

    if ($cookie) {
        $page->cookie($cookie);
        $page->size(0);
        $ldap->search( %args );
    }

    EBox::debug((scalar keys %groups) . ' groups from ' . $nPage . ' pages ');

    return %groups;
}

sub getPrincipalName 
{
    my ($ldap, $dn) = @_;
    if ($dn =~ m/,CN=ForeignSecurityPrincipals,/) {
        # ForeignSecurityPrincipals are ignored
        return undef;
    }

    my %attrs = (
                 base => $dn,
                 filter => '(objectclass=organizationalPerson)',
                 scope => 'sub',
                 attrs => ['userPrincipalName', 'sAMAccountName', 'mail']
                );

    my $result = $ldap->search(%attrs);
    my $entry = $result->shift_entry();
    if ($entry) {
        my $name = $entry->get_value('userPrincipalName');
        unless ($name) {
            $name = $entry->get_value('sAMAccountName');
        }
        unless ($name) {
            $name = $entry->get_value('mail');
        }
        $name =~ s/@.*$//;
        return $name;
    } else {
        EBox::debug("[ad-sync] can't get userPrincipalName for $dn.");
        return undef;
    }
}

