#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Univention UCS@school
#
# SPDX-FileCopyrightText: 2007-2026 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only

from __future__ import annotations

import argparse
import copy
import logging
import os
import pwd
import re
import shutil
import signal
import stat
import sys
import time
import traceback
from typing import TYPE_CHECKING

import ldap
from ldap.filter import filter_format

import univention.uldap
from ucsschool.netlogon import SqliteQueue
from univention.config_registry import ConfigRegistry

if TYPE_CHECKING:
    from univention.uldap import access


class Log(object):
    logger = None

    @classmethod
    def setup(cls):
        if cls.logger is None:
            logging.basicConfig(
                filename="/var/log/univention/ucs-school-user-logonscript-daemon.log",
                level=logging.INFO,
                format="%(asctime)s [%(levelname)s]  %(message)s",
            )
            cls.logger = logging.getLogger("main")
            cls.logger.info("INIT")

    @classmethod
    def set_debug_level(cls, level):
        """Set debug log level (e.g. to logging.DEBUG)"""
        cls.setup()
        cls.logger.setLevel(level)

    @classmethod
    def debug(cls, *args):
        cls.setup()
        cls.logger.debug(*args)

    @classmethod
    def error(cls, *args):
        cls.setup()
        cls.logger.error(*args)

    @classmethod
    def info(cls, *args):
        cls.setup()
        cls.logger.info(*args)

    @classmethod
    def warning(cls, *args):
        cls.setup()
        cls.logger.warning(*args)


class QueueHandler:
    SLEEP_INTERVAL_ON_EMPTY_QUEUE = 180
    SLEEP_INTERVAL_MINIMUM = 7
    DELAY_ON_ERROR = 5
    FN_PID = "/var/run/ucs-school-user-logonscript-daemon.pid"

    def __init__(self):
        self.ucr = ConfigRegistry()
        self.ucr.load()

        val = self.ucr.get("ucsschool/userlogon/daemon/debug/level", "3").strip()
        try:
            if int(val) >= 4:
                Log.set_debug_level(logging.DEBUG)
            elif int(val) >= 3:
                Log.set_debug_level(logging.INFO)
            elif int(val) >= 2:
                Log.set_debug_level(logging.WARNING)
            elif int(val) >= 0:
                Log.set_debug_level(logging.ERROR)
            else:
                raise ValueError()
        except ValueError:
            Log.error("invalid value in UCR variable ucsschool/userlogon/daemon/debug/level (%r)", val)

        self.lo = self.get_machine_connection()
        self.setup_config()
        self.user_queue = SqliteQueue(logger=Log)
        self.shutdown_requested = False
        self.wait_until = None
        self.fix_win10_icon_offset = self.ucr.is_true("ucsschool/userlogon/fix-win10-icon-offset", True)

        for path in self.template_paths.values():
            if not os.path.exists(path):
                Log.error("missing template file %r.", path)
                sys.exit(1)

    def get_machine_connection(self) -> access:
        """Open a machine LDAP connection, exiting the daemon on invalid credentials."""
        try:
            return univention.uldap.getMachineConnection(ldap_master=False)
        except ldap.INVALID_CREDENTIALS as exc:
            Log.error("Failed to connect to LDAP, invalid credentials: %s", exc)
            sys.exit(13)

    @staticmethod
    def _mkdir(path):
        if not os.path.isdir(path):
            os.makedirs(path)
        # copy the umc icon to the netlogon share, maybe there is a better way? ...
        if not os.path.isfile(os.path.join(path, "univention-management-console.ico")):
            shutil.copy(
                "/usr/share/ucs-school-netlogon-user-logonscripts/univention-management-console.ico",
                path,
            )
        if not os.path.isfile(os.path.join(path, "univention-portal.ico")):
            shutil.copy("/usr/share/ucs-school-netlogon-user-logonscripts/univention-portal.ico", path)

    def get_disabled_share_links(self):
        if not self._disabled_share_links:
            for k, v in self.ucr.items():
                if k.startswith("ucsschool/userlogon/disabled_share_links/"):
                    server = k.rpartition("/")[-1]
                    shares = [val.strip().rstrip("/") for val in v.split(",")]
                    self._disabled_share_links[server] = shares
        return self._disabled_share_links

    def get_script_path(self):
        if not self._script_path:
            ucsschool_netlogon_path = (
                self.ucr.get("ucsschool/userlogon/netlogon/path", "").strip().rstrip("/")
            )
            samba_netlogon_path = self.ucr.get("samba/share/netlogon/path", "").strip().rstrip("/")
            self._script_path = []
            if ucsschool_netlogon_path:
                self._script_path.append(ucsschool_netlogon_path)
            elif samba_netlogon_path:
                self._script_path.append(samba_netlogon_path)
            else:
                self._script_path.append("/var/lib/samba/netlogon/user")
                self._script_path.append(
                    "/var/lib/samba/sysvol/%s/scripts/user" % self.ucr.get("kerberos/realm", "").lower()
                )

            for path in self._script_path:
                self._mkdir(path)
        return self._script_path

    def get_global_links(self):
        # search in configRegistry for shares which are common for all users
        global_links = {}
        Log.debug("search for global links")
        share_keys = [
            x.strip()
            for x in self.ucr.get("ucsschool/userlogon/commonshares", "").split(",")
            if x.strip()
        ]
        for key in share_keys:
            # check if share exists
            Log.debug("search global links for %s" % key)
            try:
                if not self.lo.search(
                    scope="sub",
                    filter=filter_format(
                        "(&(objectClass=univentionShareSamba)(|(cn=%s)(univentionShareSambaName=%s)))",
                        (key, key),
                    ),
                    attr=["cn"],
                ):
                    continue
            except ldap.LDAPError as exc:
                Log.error("LDAP search for global links failed: key=%r: %s", key, exc)
                raise
            server = self.ucr.get("ucsschool/userlogon/commonshares/server/%s" % key)
            letter = self.ucr.get("ucsschool/userlogon/commonshares/letter/%s" % key, "").replace(
                ":", ""
            )
            if server:
                global_links[key] = {"server": server}
                if letter:
                    global_links[key]["letter"] = letter
        Log.info("got global links %s" % global_links)
        return global_links

    def get_home_path(self):
        res = ""
        if self.ucr.get("samba/homedirletter"):
            res = "{}:\\{}".format(self.ucr["samba/homedirletter"], self.myshares_name)
        if self.ucr.get("ucsschool/userlogon/mysharespath"):
            res = self.ucr["ucsschool/userlogon/mysharespath"]
        return res

    def generate_mac_script(self, uid, name, host):
        return self.get_logon_template(self.template_paths["mac_script"]).format(
            uid=uid, host=host, name=name
        )

    def write_mac_link_scripts(self, uid, homepath, links):
        if os.path.exists(homepath) and not os.path.isdir(homepath):  # may be /dev/null
            Log.error("write_mac_link_scripts: could not create script - %r is no directory", homepath)
            return

        # check existence of home
        uidnumber = 0
        gidnumber = 0
        try:
            uidnumber = pwd.getpwnam(uid)[2]
            gidnumber = pwd.getpwnam(uid)[3]
        except Exception as exc:
            Log.warning("unable to get uidNumber and gidNumber of user %r: %s", uid, exc)

        if not os.path.exists(os.path.join(homepath, "Desktop", self.desktop_folder_name_macos)):
            if not os.path.exists(homepath):
                os.mkdir(homepath, 0o700)
                os.chown(homepath, uidnumber, gidnumber)

            for path in [
                os.path.join(homepath, "Desktop"),
                os.path.join(homepath, "Desktop", self.desktop_folder_name_macos),
            ]:
                if not os.path.exists(path):
                    os.mkdir(path)
                    os.chown(path, uidnumber, gidnumber)

        # remove old scripts
        for filename in os.listdir(os.path.join(homepath, "Desktop", self.desktop_folder_name_macos)):
            try:
                if os.path.isdir(
                    os.path.join(homepath, "Desktop", self.desktop_folder_name_macos, filename)
                ):
                    shutil.rmtree(
                        os.path.join(homepath, "Desktop", self.desktop_folder_name_macos, filename)
                    )
                else:
                    os.remove(
                        os.path.join(homepath, "Desktop", self.desktop_folder_name_macos, filename)
                    )
            except EnvironmentError:
                Log.error("failed to remove %s" % filename)
                raise

        for filename in links:
            macscriptpath = os.path.join(
                homepath, "Desktop", self.desktop_folder_name_macos, "%s.app" % filename
            )
            os.mkdir(macscriptpath)
            os.chown(macscriptpath, uidnumber, gidnumber)
            macscriptfile = os.path.join(macscriptpath, filename)
            with open(macscriptfile, "w") as fp:
                fp.write(self.generate_mac_script(uid, filename, links[filename]))
                os.chmod(macscriptfile, 0o700)
                os.chown(macscriptfile, uidnumber, gidnumber)

    def get_logon_template(self, path, format_dict=None, no_icons=None):
        """
        Fetch a VBS/mac template and apply text replacements.

        :param path: str: path to template file
        :param format_dict: dict: if not None, text replacements will be
        applied with str.format(**format_dict). Attention: templates will be
        cached. They can be "compiled" with format_dict only once! Use None to
        format them individually.
        :param no_icons: list of strings: remove lines that contain the listed
        format-keys (e.g. 'my_files_link_icon' to remove icon from My Files
        link).
        :return: str: template text
        """
        if path not in self._template_cache:
            # read file into list of strings
            with open(path) as fp:
                tmp = fp.readlines()
            # remove icon lines
            for key in no_icons or []:
                try:
                    del format_dict[key]
                except KeyError:  # key not in format_dict
                    pass
                except TypeError:  # format_dict is None
                    pass
                for line in tmp:
                    if "{%s}" % key in line:
                        tmp.remove(line)
            # list 2 string
            tmp = "".join(tmp)
            # format string
            if format_dict:
                assert isinstance(format_dict, dict)
                tmp = tmp.format(**format_dict)
            self._template_cache[path] = tmp
        # return a copy, so string in cache will not be modified
        return copy.copy(self._template_cache[path])

    def generate_drive_mappings_snippet(self, mappings):
        res = ""
        lettersinuse = {}
        for key in mappings.keys():
            if mappings[key].get("letter"):
                if lettersinuse.get(mappings[key]["letter"]):
                    if lettersinuse[mappings[key]["letter"]] == mappings[key]["server"]:
                        continue
                    Log.warning(
                        "{name}: the assigned letter {letter!r} for share \\\\{server}\\{key} is "
                        'already in use by server "{lettersinuse!r}"'.format(
                            name="ucs-school-user-logonscripts-daemon",
                            letter=mappings[key]["letter"],
                            server=mappings[key]["server"],
                            key=key,
                            lettersinuse=lettersinuse[mappings[key]["letter"]],
                        )
                    )
                else:
                    res += 'MapDrive "%s:","\\\\%s\\%s"\n' % (
                        mappings[key]["letter"],
                        mappings[key]["server"],
                        key,
                    )
                    lettersinuse[mappings[key]["letter"]] = mappings[key]["server"]
        return res

    def generate_header_and_functions_snippet(self):
        str_replacements = {
            "desktop_folder_icon": self.desktop_folder_icon,
            "desktop_folder_name": self.desktop_folder_name.translate(
                str.maketrans("", "", r'\/:*?"<>|')
            ),
            "desktop_folder_path": self.desktop_folder_path,
            "domainname": self.domainname,
            "hostname": self.hostname,
            "my_files_link_icon": self.fix_win10_icon_index(self.my_files_link_icon),
            "my_files_link_name": self.my_files_link_name,
            "mypictures_name": self.mypictures_name,
            "myshares_name": self.myshares_name,
            "other_links_icon": self.fix_win10_icon_index(self.other_links_icon),
            "umc_link": self.umcLink,
            "portal_link": self.portalLink,
        }
        no_icons = []
        if not self.desktop_folder_icon:
            no_icons.append("desktop_folder_icon")
        if not self.my_files_link_icon:
            no_icons.append("my_files_link_icon")
        if not self.other_links_icon:
            no_icons.append("other_links_icon")
        return self.get_logon_template(self.template_paths["main"], str_replacements, no_icons)

    def generate_shares_shortcuts_snippet(self, links):
        res = ""
        disabled_share_links = self.get_disabled_share_links()
        for share, server in links.items():
            disabled_server_links = disabled_share_links.get(server, [])
            if "all" in disabled_server_links or any(
                re.match(disabled_link, share) for disabled_link in disabled_server_links
            ):
                continue
            res += 'CreateShareShortcut "{}","{}"\n'.format(server, share)
        return res

    def generate_teacher_umc_link_snippet(self, dn):
        try:
            is_teacher = bool(self.lo.search(base=dn, scope="base", filter=self.filterTeacher)[0])
        except (ldap.NO_SUCH_OBJECT, IndexError):
            is_teacher = False
        if is_teacher:
            return "CreateTeacherUmcLink\n"
        return ""

    def generate_ucs_portal_link_snippet(self, dn):
        return "CreateUcsPortalLink\n"

    def generate_windows_link_script(self, links, mappings, dn):
        """
        Create windows user netlogon script.

        :param links: list of tupels which contain link name and link target
        :param mappings:
        :param dn:
        :return: str: a VBS script
        """
        # create constants and functions
        script = self.generate_header_and_functions_snippet()

        # create shortcuts to shares
        if self.create_shortcuts:
            # create folder
            script += "CreateLinkFolder\n"

            # create custom folder icon
            if self.desktop_folder_icon:
                script += "CreateDesktopIcon\n"

            # create My Files link
            if self.create_myfiles_link:
                script += "CreateLinkToMyFiles\n"

            # create shortcuts to shares
            # disable individually using ucsschool/userlogon/disabled_share_links/*
            script += self.generate_shares_shortcuts_snippet(links)

        # create shortcut to umc for teachers
        if self.create_teacher_umc_link:
            script += self.generate_teacher_umc_link_snippet(dn)

        # create shortcut to umc for teachers
        if self.create_ucs_portal_link:
            script += self.generate_ucs_portal_link_snippet(dn)

        # map personal files from c:\users\<uid> to \\server\<uid>
        home_path = self.get_home_path()
        if self.create_personal_files_mapping and home_path:
            script += 'SetMyShares "%s"\n' % home_path

        # create drive mappings
        if self.create_drive_mappings:
            script += self.generate_drive_mappings_snippet(mappings)

        return script

    def write_windows_link_skripts(self, dn, uid, links, mappings):
        for path in self.get_script_path():
            script = self.generate_windows_link_script(links, mappings, dn).replace("\n", "\r\n")
            filepath = os.path.join(path, "{}.vbs".format(uid))
            with open(filepath, "w") as fp:
                fp.write(script)
            os.chmod(filepath, 0o755)  # nosec

    def get_group_memberships(self, dn):
        """Returns a frozenset of gidNumbers the specified user is member of."""
        try:
            res = self.lo.search(
                scope="sub",
                filter=filter_format("(&(objectClass=posixGroup)(uniqueMember=%s))", (dn,)),
                attr=["gidNumber"],
            )
        except ldap.LDAPError as exc:
            Log.error("LDAP search for %s failed in get_group_memberships(): %s", dn, exc)
            raise
        return frozenset([attributes["gidNumber"][0].decode("UTF-8") for _, attributes in res])

    def get_shares_with_specific_gidNumber(self, gid):
        try:
            return self.lo.search(
                scope="sub",
                filter=filter_format(
                    "(&(objectClass=univentionShareSamba)(univentionShareGid=%s))", (gid,)
                ),
                attr=["cn", "univentionShareHost", "univentionShareSambaName"],
            )
        except ldap.LDAPError as msg:
            Log.warning("LDAP-search failed for shares with gid %s: %r" % (gid, msg))
            raise

    def handle_user_change(self, dn, unchecked_uid):  # type: (str, str) -> None
        """(Re)Create a vbs logonscript for the user specified by the given user DN."""
        Log.debug("fetching data for %s", dn)
        new = self.lo.get(dn)
        if not new:
            Log.info("user %s (%s) does not exist anymore in LDAP", unchecked_uid, dn)
            for path in self.get_script_path():
                vbs_path = os.path.join(path, "{}.vbs".format(unchecked_uid))
                if os.path.exists(vbs_path):
                    Log.info("deleting netlogon script {}...".format(vbs_path))
                    os.remove(vbs_path)
            return

        uid = new.get("uid", [b"$"])[0].decode("UTF-8")
        if uid.endswith("$") or b"univentionHost" in new.get("objectClass", []):
            # if uid ends with '$', it is a machine account
            Log.debug("%s is a computer object - skipping queue item", dn)
            return
        if b"univentionGroup" in new.get("objectClass", []):
            Log.debug("%s is a group object - skipping queue item", dn)
            return

        membershipIDs = set()  # set of gidNumbers of groups the user is member of
        newGidNumber = new.get("gidNumber", [b""])[0].decode("UTF-8")
        if not newGidNumber:
            Log.debug("User %r has no gidNumber set - skipping queue item" % (uid,))
            return
        membershipIDs.add(newGidNumber)

        Log.info("updating logon script for %s", dn)

        # update of membershipIDs: add all gidNumbers of groups, the user is member of
        membershipIDs.update(self.get_group_memberships(dn))
        Log.debug("user is member of following groups: %s", membershipIDs)

        # mappings: { SHARE_NAME: { 'letter': DRIVE_LETTER, 'server': SERVER_NAME }, ... }
        mappings = {}
        # regular expression to filter out only class shares
        RE_classes = re.compile(
            "^cn=([^,]*),cn=klassen,cn=shares,ou=([^,]*),(?:ou=[^,]+,)?%s$" % re.escape(self.ldapbase)
        )
        links = {}
        # the UCR variables defines a list of hostnames that are valid share server names;
        # shares of "unknown" servers, that are not part of this list, are ignored
        validservers = frozenset(
            self.ucr.get("ucsschool/userlogon/shares/validservers", self.hostname).split(",")
        )

        # get global links: it's possible to define shares that have to be connected to every user
        for name in self.global_links.keys():
            if self.global_links[name].get("server"):
                links[name] = self.global_links[name]["server"]
                if self.global_links[name].get("letter"):
                    mappings[name] = {
                        "server": self.global_links[name]["server"],
                        "letter": self.global_links[name]["letter"],
                    }

        classShareLetter = (
            self.ucr.get("ucsschool/userlogon/classshareletter", "K").replace(":", "").strip()
        )
        for ID in membershipIDs:
            for share in self.get_shares_with_specific_gidNumber(ID):
                # linkname is identical to the sharename
                linkname = share[1]["cn"][0].decode("UTF-8")
                if "univentionShareSambaName" in share[1]:
                    linkname = share[1]["univentionShareSambaName"][0].decode("UTF-8")

                # ignore link if already in global links
                if links.get(linkname):
                    continue

                # hostname_ is either an IP or an FQDN
                hostname_ = share[1]["univentionShareHost"][0].decode("UTF-8")
                if hostname_.strip("0123456789."):  # no IP-Address
                    hostname_ = hostname_.split(".", 1)[0]  # use only hostname not FQDN

                # save link and mapping
                if hostname_ in validservers or "*" in validservers:
                    links[linkname] = hostname_
                    classmatches = RE_classes.match(share[0])
                    if classmatches and len(classmatches.groups()) == 2:
                        mappings[linkname] = {"server": hostname_, "letter": classShareLetter}

        Log.debug("user links %s", links)

        # create logon script for Windows systems
        self.write_windows_link_skripts(dn, uid, links, mappings)

        # create logon script for OSX systems
        if self.ucr.is_true("ucsschool/userlogon/mac"):
            self.write_mac_link_scripts(
                uid, new.get("homeDirectory", [b"NO-HOMEDIRECTORY-SET"])[0].decode("UTF-8"), links
            )

    def setup_signal_handling(self):
        # register handler for signal USR1
        signal.signal(signal.SIGUSR1, self.signal_usr1)
        signal.siginterrupt(signal.SIGUSR1, False)
        signal.signal(signal.SIGTERM, self.signal_term)
        signal.siginterrupt(signal.SIGTERM, False)
        with open(self.FN_PID, "w") as fd:
            fd.write(str(os.getpid()))
        os.chown(self.FN_PID, 0, 0)
        os.chmod(self.FN_PID, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)

    def teardown_signal_handling(self):
        """Removes PID file, if it still exists."""
        if os.path.isfile(self.FN_PID):
            os.remove(self.FN_PID)

    def signal_term(self, signal_number, frame):
        """shut down gracefully"""
        Log.warning("signal received - shutting down gracefully")
        self.shutdown_requested = True

    def signal_usr1(self, signal_number, frame):
        """
        If signal USR1 is received, the remaining waiting time self.wait_util is reduced
        to time.time()+self.SLEEP_INTERVAL_MINIMUM if more than SLEEP_INTERVAL_MINIMUM
        seconds are left to wait.
        """
        minimum_wait_until = time.time() + self.SLEEP_INTERVAL_MINIMUM
        if self.wait_until and self.wait_until > minimum_wait_until:
            Log.info(
                "signal received - reduced wait time by %.1f seconds",
                self.wait_until - minimum_wait_until,
            )
            self.wait_until = minimum_wait_until

    def fix_win10_icon_index(self, icon_path):
        """
        If `icon_path` ends in an icon index, add VBS code to handle Win10
        index displacement.

        Attention: This will add quotation marks around the path. The VBS
        template must not add them too.
        """
        unmodified_icon_path = '"{}"'.format(icon_path)
        if not self.fix_win10_icon_offset:
            return unmodified_icon_path
        path, _, index = icon_path.rpartition(",")
        try:
            index = int(index)
        except ValueError:
            return unmodified_icon_path
        if not path.lower().endswith("\\imageres.dll"):
            return unmodified_icon_path
        return '"{}," & Win10FixIconIndex({})'.format(path, index)

    def setup_config(self):
        self.hostname = self.ucr["hostname"]
        self.domainname = self.ucr["domainname"]
        self.ldapbase = self.ucr["ldap/base"]

        self.global_links = self.get_global_links()
        self.desktop_folder_path = self.ucr.get("ucsschool/userlogon/shares_folder_parent_path")
        if self.desktop_folder_path:
            self.desktop_folder_path = self.desktop_folder_path.strip('"').rstrip("\\")
            self.desktop_folder_path = 'oShellScript.ExpandEnvironmentStrings("{}")'.format(
                self.desktop_folder_path
            )
        else:
            self.desktop_folder_path = "objFolderItem.Path"
        self.desktop_folder_name = self.ucr.get("ucsschool/userlogon/shares_foldername", "Eigene Shares")
        self.desktop_folder_name_macos = self.ucr.get(
            "ucsschool/userlogon/mac/foldername", self.desktop_folder_name
        )
        self.desktop_folder_icon = self.ucr.get(
            "ucsschool/userlogon/shares_folder_icon"
        )  # '%SystemRoot%\system32\imageres.dll,143'
        self.my_files_link_name = self.ucr.get("ucsschool/userlogon/my_files_link_name", "Meine Dateien")
        self.my_files_link_icon = self.ucr.get(
            "ucsschool/userlogon/my_files_link_icon"
        )  # '%SystemRoot%\system32\imageres.dll,207'
        self.other_links_icon = self.ucr.get(
            "ucsschool/userlogon/other_links_icon"
        )  # '%SystemRoot%\system32\imageres.dll,193'
        self.myshares_name = self.ucr.get("ucsschool/userlogon/myshares/name", "Eigene Dateien")
        self.mypictures_name = self.ucr.get("ucsschool/userlogon/mypictures/name", "Eigene Bilder")
        self.create_drive_mappings = self.ucr.is_true("ucsschool/userlogon/create_drive_mappings", True)
        self.create_myfiles_link = self.ucr.is_true("ucsschool/userlogon/create_myfiles_link", True)
        self.create_personal_files_mapping = self.ucr.is_true(
            "ucsschool/userlogon/myshares/enabled", False
        )
        self.create_shortcuts = self.ucr.is_true("ucsschool/userlogon/create_shortcuts", True)
        self.create_teacher_umc_link = self.ucr.is_true(
            "ucsschool/userlogon/create_teacher_umc_link", True
        )
        self.create_ucs_portal_link = self.ucr.is_true(
            "ucsschool/userlogon/create_ucs_portal_link", True
        )

        self.strTeacher = self.ucr.get("ucsschool/ldap/default/container/teachers", "lehrer")
        self.strStaff = self.ucr.get(
            "ucsschool/ldap/default/container/teachers-and-staff", "lehrer und mitarbeiter"
        )
        self.umcLink = self.ucr.get(
            "ucsschool/userlogon/umclink/link",
            "http://%s.%s/univention/management/" % (self.hostname, self.domainname),
        )
        self.portalLink = self.ucr.get(
            "ucsschool/userlogon/portallink/link", "https://%s.%s/" % (self.hostname, self.domainname)
        )
        self.reTeacher = re.compile(
            self.ucr.get(
                "ucsschool/userlogon/umclink/re",
                "^(.*),cn=(%s|%s),cn=users,ou=([^,]+),(?:ou=[^,]+,)?%s$"
                % (re.escape(self.strTeacher), re.escape(self.strStaff), re.escape(self.ldapbase)),
            )
        )
        self.filterTeacher = self.ucr.get(
            "ucsschool/userlogon/umclink/filter",
            "(|(objectClass=ucsschoolTeacher)(objectClass=ucsschoolStaff))",
        )
        self.template_paths = {
            "main": "/usr/share/ucs-school-netlogon-user-logonscripts/net-logon-script.vbs",
            "mac_script": "/usr/share/ucs-school-netlogon-user-logonscripts/mac_script",
        }

        value = self.ucr.get("ucsschool/userlogon/daemon/check-interval", "").strip()
        if value:
            try:
                self.SLEEP_INTERVAL_ON_EMPTY_QUEUE = int(value)
            except ValueError:
                Log.error(
                    "UCR variable ucsschool/userlogon/daemon/check-interval contains an invalid value "
                    "(%r). Using default of %s seconds.",
                    value,
                    self.SLEEP_INTERVAL_ON_EMPTY_QUEUE,
                )
        Log.debug("SLEEP_INTERVAL_ON_EMPTY_QUEUE = %s", self.SLEEP_INTERVAL_ON_EMPTY_QUEUE)

        value = self.ucr.get("ucsschool/userlogon/daemon/error-delay", "").strip()
        if value:
            try:
                self.DELAY_ON_ERROR = int(value)
            except ValueError:
                Log.error(
                    "UCR variable ucsschool/userlogon/daemon/error-delay contains an invalid value "
                    "(%r). Using default of %s seconds.",
                    value,
                    self.DELAY_ON_ERROR,
                )
        Log.debug("DELAY_ON_ERROR = %s", self.DELAY_ON_ERROR)

        value = self.ucr.get("ucsschool/userlogon/daemon/check-interval-minimum", "").strip()
        if value:
            try:
                self.SLEEP_INTERVAL_MINIMUM = int(value)
            except ValueError:
                Log.error(
                    "UCR variable ucsschool/userlogon/daemon/check-interval-minimum contains an "
                    "invalid value (%r). Using default of %s seconds.",
                    value,
                    self.SLEEP_INTERVAL_MINIMUM,
                )
        Log.debug("SLEEP_INTERVAL_MINIMUM = %s", self.SLEEP_INTERVAL_MINIMUM)

        self._disabled_share_links = {}
        self._template_cache = {}
        self._script_path = []

    def run(self):
        """
        Iterate over all queue items. Each queue item is removed and in case of an
        error readded at the bottom of the queue. If the queue is empty, the loop
        will wait some time until the next queue check is performed.
        """
        try:
            self.setup_signal_handling()  # register signal USR1 and create PID file

            while not self.shutdown_requested:
                empty_run = True
                user_dn, username = self.user_queue.query_next_user()
                if user_dn is not None:
                    empty_run = False
                    Log.debug("found %s in queue", user_dn)
                    # queue is not empty
                    try:
                        self.user_queue.remove(user_dn)  # remove user from queue
                        self.handle_user_change(user_dn, username)
                    except (SystemExit, KeyboardInterrupt):
                        # handling may be incomplete, readd user dn to queue:
                        self.user_queue.add([(user_dn, username)])
                        self.shutdown_requested = True
                    except Exception as exc:
                        # handling failed, readd user dn to queue:
                        self.user_queue.add([(user_dn, username)])
                        Log.error('An error occured while processing "%s": %r', user_dn, exc)
                        Log.debug("EXCEPTION:\n%s", traceback.format_exc())
                        time.sleep(self.DELAY_ON_ERROR)

                if self.user_queue.query_next_user()[0] is None and not self.shutdown_requested:
                    if not empty_run:
                        Log.info("all items processed... sleeping...")
                    else:
                        Log.debug("queue is empty... sleeping...")
                    # no more DNs in queue, wait some time for next check
                    self.wait_until = time.time() + self.SLEEP_INTERVAL_ON_EMPTY_QUEUE
                    while not self.shutdown_requested and time.time() < self.wait_until:
                        time.sleep(1)
                    Log.debug("just woke up...")
        finally:
            self.teardown_signal_handling()  # remove PID file
        if self.shutdown_requested:
            Log.info("Shutdown as requested")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-d", "--debug", default=False, action="store_true", help="show debug output")
    options = parser.parse_args()

    if options.debug:
        Log.set_debug_level(logging.DEBUG)
    handler = QueueHandler()
    handler.run()


if __name__ == "__main__":
    main()
