#!/usr/bin/python3 -u
"""
Commands for managing Univention ID Broker system.

Add, remove and show ID Broker specific settings stored in LDAP.
Settings are grouped (mappings, secrets) and each setting
is a dict with keys and values.

Add and remove service providers to the ID Broker.
For each user and group a unique pseudonym is generated and stored in an
extended_attribute on the user and group specific to that service provider.

If no credentials are provided the machine account is used for authentication.
"""
#
# Copyright 2022-2023 Univention GmbH
#
# http://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <http://www.gnu.org/licenses/>.
#

import json
import re
import sys
import uuid
from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter

from univention.config_registry import ConfigRegistry
from univention.id_broker.pseudonyms import generate_pseudonym
from univention.id_broker.pseudonyms_udm_host import (
    IdBrokerPseudonymisationNoMapping,
    get_service_provider_attribute,
    get_service_provider_attribute_dn,
    get_service_provider_secret_dn,
    get_settings_data_content,
)
from univention.udm import UDM, ModifyError, UnknownProperty
from univention.udm.encoders import Base64Bzip2BinaryProperty

NUM_OF_SERVICE_PROVIDERS = 30
MAPPING_PATTERN = r"^idBrokerPseudonym00([0-9]{2})$"


def _remove_setting(opt, udm, settings):
    dn = settings[opt.setting]
    remove_setting(udm, dn, opt.key)


def remove_setting(udm, dn, key):
    obj = udm.get("settings/data").get(dn)
    setting = json.loads(obj.props.data.raw)
    if key in setting:
        del setting[key]
    obj.props.data = Base64Bzip2BinaryProperty("data", raw_value=json.dumps(setting).encode("utf-8"))
    obj.save()


def check_mapping_is_allowed(opt):
    if opt.setting == "mappings":
        matching = re.match(MAPPING_PATTERN, opt.value)
        if matching and int(matching.group(1)) <= NUM_OF_SERVICE_PROVIDERS:
            return True
        return False
    return True


def _add_setting(opt, udm, settings):
    if not check_mapping_is_allowed(opt):
        print(
            f'Error: mappings value "{opt.value}" does not follow convention "idBrokerPseudonym00XX", where "00XX"'
            f"has to consist of digits."
        )
        sys.exit(1)

    dn = settings[opt.setting]
    add_setting(udm, dn, opt.key, opt.value)


def add_setting(udm, dn, key, value):
    obj = udm.get("settings/data").get(dn)
    setting = json.loads(obj.props.data.raw)
    setting[key] = value
    obj.props.data = Base64Bzip2BinaryProperty("data", raw_value=json.dumps(setting).encode("utf-8"))
    obj.save()


def _show_setting(opt, udm, settings):
    dn = settings[opt.setting]
    show_setting(udm, dn, opt.key)


def show_setting(udm, dn, key=None):
    settings = get_settings_data_content(dn, udm=udm)
    if key:
        print(json.dumps(settings.get(key, "{}"), indent=4, sort_keys=True))
    else:
        print(json.dumps(settings, indent=4, sort_keys=True))


def add_service(opt, udm, settings):
    service_provider = opt.name
    secret = opt.secret or str(uuid.uuid4())

    # get the pseudonym attribute for the service provider
    try:
        extended_attribute = get_service_provider_attribute(service_provider, udm=udm)
        if not opt.overwrite:
            print(
                f"Service Provider {service_provider} already known; uses {extended_attribute}. "
                f"We will abort here. Use --overwrite to continue (would overwrite the pseudonym for all objects)"
            )
            sys.exit(1)
        else:
            print(
                f"Service Provider {service_provider} already known. "
                f"Will re-generate {extended_attribute} for all objects."
            )
    except IdBrokerPseudonymisationNoMapping:
        # get unused idBrokerPseudonymXXXX attribute
        used_attributes = get_settings_data_content(
            get_service_provider_attribute_dn(), udm=udm
        ).values()
        remaining_service_provider_slots = NUM_OF_SERVICE_PROVIDERS - len(used_attributes)
        if remaining_service_provider_slots == 0:
            print(
                "Error: the maximum number of registrable service providers has"
                " been reached. No additional service providers can be added."
            )
            sys.exit(1)
        elif remaining_service_provider_slots <= 5:
            print(
                "Warning: reaching the limit of registrable service providers."
                f" Only {remaining_service_provider_slots} more service providers can be registered.\n"
            )

        for i in range(1, NUM_OF_SERVICE_PROVIDERS + 1):
            extended_attribute = f"idBrokerPseudonym{i:04d}"
            if extended_attribute not in used_attributes:
                break
        else:
            # prevent 'extended_attribute' referenced before assignment
            print(
                "Error: the maximum number of registrable service providers has"
                " been reached. No additional service providers can be added."
            )
            sys.exit(1)
        print(f"Using {extended_attribute}")

    add_setting(udm, settings["secrets"], service_provider, secret)
    add_setting(udm, settings["mappings"], service_provider, extended_attribute)

    def _add_extended_attribute(obj, secret, extended_attribute):
        entry_uuid = obj.props.ucsschoolRecordUID
        school_authority = obj.props.ucsschoolSourceUID
        pseud = generate_pseudonym(secret, entry_uuid, school_authority)
        try:
            setattr(obj.props, extended_attribute, pseud)
        except UnknownProperty:
            print(
                "  Warning: Skipping. Tried to modify extended attribute"
                f" {extended_attribute} but it does not exist ({obj.dn})."
            )
        except ModifyError:
            print(f"  Warning: Skipping. Modifying this object is not allowed ({obj.dn}).")
        else:
            obj.save()

    for entity in ["users/user", "groups/group", "container/ou"]:
        mod = udm.get(entity)
        objs = mod.search("(&(ucsschoolSourceUID=*)(ucsschoolRecordUID=*))")
        for obj in objs:
            if obj.props.ucsschoolSourceUID and obj.props.ucsschoolRecordUID:
                print(f"Generating pseudonym for {entity.split('/')[1]} {obj.dn}.")
                _add_extended_attribute(obj, secret, extended_attribute)


def remove_service(opt, udm, settings):
    service_provider = opt.name

    try:
        extended_attribute = get_service_provider_attribute(service_provider, udm=udm)
    except IdBrokerPseudonymisationNoMapping:
        print(f"Error: --name {service_provider} is not registered as service provider.")
        sys.exit(1)

    remove_setting(udm, settings["secrets"], service_provider)
    remove_setting(udm, settings["mappings"], service_provider)

    def _remove_extended_attribute(obj, extended_attribute):
        try:
            setattr(obj.props, extended_attribute, None)
        except UnknownProperty:
            print(
                "  Warning: Skipping. Tried to modify extended attribute"
                f" {extended_attribute} but it does not exist ({obj.dn})."
            )
        else:
            obj.save()

    for entity in ["users/user", "groups/group", "container/ou"]:
        mod = udm.get(entity)
        objs = mod.search(f"{extended_attribute}=*")
        for obj in objs:
            if obj.props.ucsschoolSourceUID and obj.props.ucsschoolRecordUID:
                print(f"Removing pseudonym for {entity.split('/')[1]} {obj.dn}.")
                _remove_extended_attribute(obj, extended_attribute)


def main():
    ucr = ConfigRegistry()
    ucr.load()
    settings = {
        "mappings": get_service_provider_attribute_dn(),
        "secrets": get_service_provider_secret_dn(),
    }

    parser = ArgumentParser(description=__doc__, formatter_class=RawDescriptionHelpFormatter)
    parser.add_argument(
        "--binddn",
        metavar="DN",
        help="DN of account to use to write to LDAP (e.g. uid=Administrator,cn=users,..)",
    )
    parser.add_argument(
        "--bindpwdfile",
        metavar="FILE",
        type=FileType("r", encoding="UTF-8"),
        help="file containing the password of --binddn",
    )

    subparsers = parser.add_subparsers(
        title="subcommands",
        description="valid subcommands",
        required=True,
        dest="command",
    )

    parser_add = subparsers.add_parser("addsetting", help="add/modify setting")
    parser_add.add_argument(
        "setting",
        choices=settings.keys(),
        help="type of setting",
    )
    parser_add.add_argument("--key", "-k", type=str, required=True, help="key of setting")
    parser_add.add_argument("--value", "-v", type=str, required=True, help="value of setting")
    parser_add.set_defaults(func=_add_setting)

    parser_show = subparsers.add_parser("showsetting", help="show settings")
    parser_show.add_argument(
        "setting",
        choices=settings.keys(),
        help="type of setting",
    )
    parser_show.add_argument("--key", "-k", type=str, required=False, help="key of setting")
    parser_show.set_defaults(func=_show_setting)

    parser_remove = subparsers.add_parser("removesetting", help="remove key from setting")
    parser_remove.add_argument(
        "setting",
        choices=settings.keys(),
        help="type of setting",
    )
    parser_remove.add_argument("--key", "-k", type=str, required=True, help="key of setting")
    parser_remove.set_defaults(func=_remove_setting)

    parser_add = subparsers.add_parser(
        "addservice",
        help="Add a new service provider to the ID Broker."
        " This will generate a pseudonym for each provisioned"
        " user/group/school and store it in an extended_attribute"
        " on that object specific to this service provider."
        " Newly provisioned users/groups/school will have a pseudonym for this service provider added",
    )
    parser_add.add_argument("--name", type=str, required=True, help="id of the service provider")
    parser_add.add_argument(
        "--secret",
        type=str,
        required=False,
        help="Additional secret string that is used in the hash function for the pseudonym generation",
    )
    parser_add.add_argument(
        "--overwrite",
        action="store_true",
        help="Overwrite settings and all service specific pseudonyms if NAME already exists",
    )
    parser_add.set_defaults(func=add_service)

    parser_remove = subparsers.add_parser(
        "removeservice",
        help="Remove an added service provider from the ID Broker."
        " Existing pseudonym will be removed from users/groups/schools",
    )
    parser_remove.add_argument("--name", type=str, required=True, help="id of service provider")
    parser_remove.set_defaults(func=remove_service)

    opt = parser.parse_args()
    if not opt.binddn:
        udm = UDM.machine().version(2)
    else:
        if not opt.bindpwdfile:
            parser.error("if --binddn is given also provide --bindpwdfile")
        server = ucr["ldap/master"]
        server_port = ucr["ldap/master/port"]
        password = opt.bindpwdfile.read().strip()
        udm = UDM.credentials(opt.binddn, password, server=server, port=server_port).version(2)

    opt.func(opt, udm, settings)


if __name__ == "__main__":
    main()
