import ldap
from ldap.controls import SimplePagedResultsControl
import logging
from typing import Optional, Tuple

from django.conf import settings

from idm.sync.ldap import models


_PAGE_SIZE = 100
log = logging.getLogger(__name__)


class NoPreviousADActionsFound(RuntimeError):
    """
    Не смогли найти в журнале AD действий нужное
    """
    pass


def connect(client, uri, user, password):
    log.info('connecting to "%s" with user "%s"', uri, user)
    ldo = client.initialize(uri)

    ldo.set_option(client.OPT_DEBUG_LEVEL, 255)
    ldo.set_option(client.OPT_NETWORK_TIMEOUT, 1)
    ldo.set_option(client.OPT_PROTOCOL_VERSION, client.VERSION3)
    ldo.set_option(client.OPT_REFERRALS, 0)
    ldo.set_option(client.OPT_TIMEOUT, 1)
    ldo.set_option(client.OPT_X_TLS, client.OPT_X_TLS_DEMAND)
    ldo.set_option(client.OPT_X_TLS_CACERTFILE, settings.CA_BUNDLE)
    ldo.set_option(client.OPT_X_TLS_DEMAND, True)
    ldo.set_option(client.OPT_X_TLS_REQUIRE_CERT, client.OPT_X_TLS_NEVER)
    # следующая опция должна выставляться после всех остальных TLS-опций, иначе они игнорируются
    ldo.set_option(client.OPT_X_TLS_NEWCTX, 0)

    ldo.start_tls_s()
    ldo.simple_bind_s(user, password)
    return ldo


class IterableLDAPSearchConnector(object):
    """
    Коннектор к LDAP, позволяющий совершать поисковые запросы и итерироваться по их результатам
    """
    def __init__(self, user, password):
        self.ldo = connect(ldap, settings.AD_LDAP_HOST, user, password)
        self.lc = SimplePagedResultsControl(True, size=_PAGE_SIZE, cookie='')

    def __del__(self):
        self.ldo.unbind_s()

    def _get_page(self, base, search_flt, search_fields, serverctrls, first=False):
        messid = None
        if not first:
            pctrls = [c for c in serverctrls if c.controlType == SimplePagedResultsControl.controlType]
            if pctrls:
                if pctrls[0].cookie:
                    self.lc.cookie = pctrls[0].cookie
                    messid = self.ldo.search_ext(
                        base, ldap.SCOPE_SUBTREE, search_flt,
                        search_fields, serverctrls=[self.lc]
                    )
                    rtype, rdata, rmsgid, serverctrls = self.ldo.result3(messid)
                    return rdata, serverctrls
            else:
                log.warning('server ignores RFC 2696 control.')

        else:
            messid = self.ldo.search_ext(
                base, ldap.SCOPE_SUBTREE, search_flt,
                search_fields, serverctrls=[self.lc]
            )
            rtype, rdata, rmsgid, serverctrls = self.ldo.result3(messid)
            return rdata, serverctrls
        return (None, None)

    def search(self, base, search_flt, search_fields):
        """
        Запросить LDAP и вернуть итератор по всем найденным записям.
        """
        rdata, serverctrls = self._get_page(base, search_flt, search_fields, None, True)

        while rdata is not None:
            for entry in rdata:
                yield entry
            rdata, serverctrls = self._get_page(base, search_flt, search_fields, serverctrls)


class NewLDAP(object):
    """
    Коннектор к LDAP для многократной проверки активности пользователей в AD
    """
    active_users_ou_list = settings.AD_ACTIVE_USERS_OU
    old_users_ou = settings.AD_LDAP_OLD_USERS_OU
    ldap_client = ldap

    def __init__(self):
        self.ldap = None

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, type, value, traceback):
        self.disconnect()

    def connect(self):
        if self.ldap is not None:
            return

        self.ldap = connect(self.ldap_client, settings.AD_LDAP_HOST, settings.AD_LDAP_USERNAME, settings.AD_LDAP_PASSWD)

    def disconnect(self):
        """Отключается от LDAP"""

        if self.ldap is not None:
            self.ldap.unbind_s()
            self.ldap = None
            log.info('disconnected from ldap')

    @staticmethod
    def report_ad_action(user, ad_action, ad_reason_data=None, **kwargs):
        action_name = 'user_ad_%s' % ad_action
        if ad_reason_data is None:
            ad_reason_data = {}
        data = ad_reason_data.copy()
        if kwargs:
            data.update(kwargs)

        user.actions.create(
            data=data,
            action=action_name,
        )

    @staticmethod
    def find_last_ad_action(user, action):
        """
        Найти в истории действий с AD последнее действие типа {action} для пользователя {user}
        """
        ad_action = user.actions.filter(action='user_ad_%s' % action).order_by('-added').first()
        if ad_action is None:
            raise NoPreviousADActionsFound('Cannot find AD action %s for user %s', (action, user.username))

        return ad_action

    def enable_user_account(self, user, account, ad_reason_data=None):
        """
        Заново активировать учетку пользователя в AD
        """
        try:
            account.enable()
        except Exception:
            log.exception('Cannot enable user %s in AD', account.username)
            return False

        log.info('Account was enabled for user %s', account.username)

        self.report_ad_action(user, 'enable', ad_reason_data)
        return True

    def move_account_to_oldusers(self, user, account, ad_reason_data=None):
        """
        Перенести учетку пользователя в OU=OLD Users
        """
        # Переносим товарища в Old Users, только если он еще не там
        original_ou = account.org_unit
        log.info('Moving user %s" to Old Users', user.username)

        try:
            account.move_to_old()
        except Exception:
            log.exception('Cannot move user %s to Old Uses', user.username)
            return False

        self.report_ad_action(user, 'move_to_old', ad_reason_data, original_ou=original_ou)
        return True

    def move_account_back(self, user, account, ad_reason_data=None):
        """
        Восстановить пользователя в том OU, в котором он был до переноса в Old Users
        """
        move_to = None
        try:
            ad_action = self.find_last_ad_action(user, 'move_to_old')
            move_to = ad_action.data['original_ou']
            account.move_to(move_to)
        except Exception:
            log.exception('Cannot move user %s back to %s', user.username, move_to)
            return False

        self.report_ad_action(user, 'move_back', ad_reason_data)
        return True

    def is_user_active(self, username):
        """Возвращает True, если пользователь с логином login заведен в AD и активен"""

        log.info('searching user %s', username)
        account = self.search_user(username)
        if account is None:
            result = False
        else:
            result = account.is_active()
        return result

    def disable_user(self,
                     user,
                     move_to_old: bool = True,
                     ad_reason_data: dict = None) -> Tuple[bool, bool, bool]:
        """
        Отключает учетную запись в AD.

        Если запись была полностью отключена (и аккаунт задисейблен и в Old Users перенесли),
        то возвращает True.

        Так же, True возвращается, если не удалось найти сотрудника ни в Users, ни в Old Users.
        """
        log.info('searching user %s', user.username)
        account = self.search_user(user.username)

        if account is None:
            # сотрудник не найден ни в группе текущих сотрудников, ни среди уволенных
            log.warning('user with username %s was not found in any AD group', user.username)
            return True, True, True

        blocked = False
        removed_from_groups = False
        if account.is_active():
            try:
                blocked = self.block_user(user, account, ad_reason_data)
            except Exception:
                log.exception('Cannot disable user %s in AD', user.username)
        else:
            log.warning('User %s already blocked', user.username)
            blocked = True

        if move_to_old:
            # удаляем пользователя из всех групп
            # на результат блокировки это не влияет
            account = self.search_user(user.username)
            try:
                log.info('Removing user %s from groups', user.username)
                removed_from_groups = account.remove_from_groups()
            except Exception:
                log.exception('Cannot remove user %s from groups', user.username)

            if account.is_old():
                # сотрудник уже перенесен в группу уволенных, переносить его не нужно
                log.warning('User with username %s already in old users', user.username)
                was_moved_to_old = True
            else:
                was_moved_to_old = self.move_account_to_oldusers(user, account, ad_reason_data)
            success = blocked and was_moved_to_old
        else:
            success = blocked

        return success, removed_from_groups, blocked

    def block_user(self, user, account, ad_reason_data=None):
        try:
            account.disable()
            log.info('Account was disabled for user %s', user.username)
        except Exception:
            log.exception('Could not disable account for user %s', user.username)
            return False
        self.report_ad_action(user, 'disable', ad_reason_data)
        return True

    def restore_user(self, user, ad_reason_data=None):
        """
        Восстановить заблокированного IDM пользователя в AD до состояния,
        в котором пользователь был до блокировки: вернуть в группы, активировать.
        """
        account = self.search_user(user.username)
        if account is None:
            log.warning('Tried to restore non-existing user %s', user.username)
            return True

        if account.is_old():
            # у пользователя были отозваны все группы и он был перенесен в OU=Old Users
            moved_back = self.move_account_back(user, account, ad_reason_data)
            # пользователь уже в своем обычном OU, надо забрать его новый CN
            account = self.search_user(user.username)
        else:
            moved_back = True

        if account.is_active():
            log.warning('Account is already enabled for user %s', user.username)
            enabled = True
        else:
            enabled = self.enable_user_account(user, account, ad_reason_data)

        return moved_back and enabled

    def add_user_to_ad_group(self, user, group_dn, ad_reason_data=None):
        """
        Возвращает результат записи пользователя login в AD группу ad_group.
        """
        account = self.search_user(user.username)

        if account is None:
            log.warning('Tried to add non-existing user %s to group %s', user.username, group_dn)
            return False

        try:
            result = account.add_to_group(group_dn)
        except Exception:
            log.exception('Cannot add user %s to group %s', user.username, group_dn)
            raise

        if result is not False:
            self.report_ad_action(
                user, 'add_to_group', ad_reason_data, group=group_dn
            )

        return True

    def remove_user_from_ad_group(self, user, group_dn, ad_reason_data=None):
        """
        Возвращает результат удаления пользователя login из AD группы ad_group.
        """

        account = self.search_user(user.username)

        if account is None:
            log.warning('Tried to remove non-existing user %s from group %s', user.username, group_dn)
            return False

        try:
            result = account.remove_from_group(group_dn)
        except Exception:
            log.exception('Cannot remove user %s from group %s', user.username, group_dn)
            raise

        if result is not False:
            self.report_ad_action(
                user, 'remove_from_group', ad_reason_data, group=group_dn
            )
        return True

    def search_user(self, username):
        return models.AccountManager(connector=self.ldap).search(username)
