#!/usr/bin/python3 -u
#
# Copyright 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 argparse
import getpass
import sys
from dataclasses import dataclass
from typing import List, Optional

from keycloak import KeycloakAdmin
from keycloak.exceptions import KeycloakAuthenticationError

from ucsschool.lib.models.school import School
from ucsschool.lib.models.user import User
from ucsschool.lib.models.utils import ucr
from univention.admin.uldap import access, getAdminConnection
from univention.udm import UDM
from univention.udm.exceptions import NoObject


def user_output(msg: Optional[str] = "", **kwargs):
    """Wrapper around print, to allow other output methods"""
    print(msg, **kwargs)


def user_input(prompt: str) -> str:
    """Wrapper around input, to allow other input methods"""
    return input(prompt)


def user_password_input(prompt: str) -> str:
    """Wrapper around getpass, to allow other password input methods"""
    return getpass.getpass(prompt=prompt)


def get_keycloak_connection(**config_kwargs):
    return KeycloakAdmin(**config_kwargs)


def get_ldap_admin_connection():
    return getAdminConnection()[0]


def get_udm_connection():
    return UDM.admin().version(2)


@dataclass
class SchoolAuthority:
    user: Optional[User]
    schools: List[School]
    identity_provider: Optional[str]


class TermColors:
    LOG_OUTPUT = "\033[93m"
    ERROR = "\033[0;31m"
    ENDC = "\033[0m"


class SchoolAuthCleaner:
    INTERACTIVE_MODE = 0
    DRY_RUN_MODE = 1
    FORCE_MODE = 2

    def __init__(
        self,
        keycloak_config: dict,
        school_authority_name: str,
        mode: int = INTERACTIVE_MODE,
    ):
        # These connections will be instantiated at first use,
        # to make it easier to trap/debug connection errors.
        self._keycloak_connection: Optional[KeycloakAdmin] = None
        self._udm_connection: Optional[UDM] = None
        self._ldap_connection: Optional[access] = None

        self.keycloak_config = keycloak_config
        self.school_authority_name = school_authority_name
        self.ldap_base: str = ucr.get("ldap/base")

        self.mode = mode

    @property
    def keycloak_connection(self):
        if not self._keycloak_connection:
            try:
                self._keycloak_connection = get_keycloak_connection(
                    **self.keycloak_config,
                )
            except KeycloakAuthenticationError:
                sys.exit(self._error("Invalid Keycloak credentials"))
            except Exception as exc:
                sys.exit(self._error("Keycloak connection failed", exc))
        return self._keycloak_connection

    @property
    def ldap_connection(self):
        if not self._ldap_connection:
            try:
                self._ldap_connection = get_ldap_admin_connection()
            except Exception as exc:
                sys.exit(self._error("Could not get ldap admin connection", exc))
        return self._ldap_connection

    @property
    def udm_connection(self):
        if not self._udm_connection:
            try:
                self._udm_connection = get_udm_connection()
            except Exception as exc:
                sys.exit(self._error("UDM connection failed", exc))
        return self._udm_connection

    def _error(self, msg: str, exc: Optional[Exception] = None, term_colors=False) -> str:
        err_str = f"ERROR: {msg}"
        if exc:
            return f"{err_str}: {exc}"
        return err_str

    def _print_error(self, error_str: str):
        """Displays the output in red"""
        user_output(f"{TermColors.ERROR}{error_str}{TermColors.ENDC}")

    def _wrap_logged_output(self, call, *args, **kwargs):
        """Wraps a command and displays the output in yellow"""
        user_output(TermColors.LOG_OUTPUT, end="")
        try:
            call(*args, **kwargs)
        finally:
            user_output(TermColors.ENDC, end="")

    def _list_identity_providers(self):
        return [idp["alias"] for idp in self.keycloak_connection.get_idps()]

    def _get_keycloak_idp_alias(self):
        aliases = self._list_identity_providers()
        aliases_lower = [alias.lower() for alias in aliases]

        try:
            return aliases[aliases_lower.index(self.school_authority_name.lower())]
        except ValueError:
            return None

    def _get_school_authority(self) -> Optional[SchoolAuthority]:
        schools = [
            school
            for school in School.get_all(self.ldap_connection)
            if school.name.startswith(f"{self.school_authority_name}-")
        ]
        try:
            user = self.udm_connection.get("users/user").get(
                dn=f"uid=provisioning-{self.school_authority_name},cn=users,{self.ldap_base}"
            )
        except NoObject:
            user = None
        identity_provider = self._get_keycloak_idp_alias()

        if schools or user or identity_provider:
            return SchoolAuthority(
                user=user,
                schools=schools,
                identity_provider=identity_provider,
            )
        else:
            return None

    def _delete(self, school_authority: SchoolAuthority) -> None:
        deletion_errors = []

        try:
            if school_authority.identity_provider:
                user_output(f"Deleting Keycloak-IDP: {school_authority.identity_provider}.")
                try:
                    self._wrap_logged_output(
                        self.keycloak_connection.delete_idp,
                        school_authority.identity_provider,
                    )
                except Exception as exc:
                    err_output = self._error(f"Keycloak-IDP: {school_authority.identity_provider}", exc)
                    deletion_errors.append(err_output)
                    self._print_error(err_output)
                finally:
                    user_output()

            if school_authority.user:
                user_output(f"Deleting user: {school_authority.user.dn}.")
                try:
                    self._wrap_logged_output(school_authority.user.delete)
                except Exception as exc:
                    err_output = self._error(f"User: {school_authority.user.dn}", exc)
                    deletion_errors.append(err_output)
                    self._print_error(err_output)
                finally:
                    user_output()

            for school in school_authority.schools:
                user_output(f"Deleting school: {school.dn}.")
                try:
                    self._wrap_logged_output(school.remove, self.ldap_connection)
                except Exception as exc:
                    err_output = self._error(f"School: {school.dn}", exc)
                    deletion_errors.append(err_output)
                    self._print_error(err_output)
                finally:
                    user_output()

        except KeyboardInterrupt:
            # If we stopped the script prematurely because it was running too
            # long, we don't need the traceback.
            pass

        finally:
            # Always let the script operator know about the errors we collected.
            if deletion_errors:
                user_output(
                    "There were errors processing deletion of the school authority. "
                    "Please fix any errors and rerun the script:\n"
                )
                for error in deletion_errors:
                    user_output(error)
                sys.exit(1)

    def clean(self):
        school_authority = self._get_school_authority()
        if school_authority:
            user_output("The following objects will be deleted:\n")

            if school_authority.identity_provider:
                user_output(f"Keycloak-IDP: {school_authority.identity_provider}")
            if school_authority.user:
                user_output(f"User: {school_authority.user.dn}")
            if school_authority.schools:
                user_output("Schools:")
                for school in school_authority.schools:
                    user_output(f"\t{school.dn}")
                user_output(
                    "\nAdditionally all other LDAP-entries belonging to the "
                    "schools listed will be deleted as well."
                )

            if self.mode == self.DRY_RUN_MODE:
                user_output("Aborting. Dry run only.")
            elif self.mode == self.FORCE_MODE:
                user_output("Applying forceful deletion.")
                self._delete(school_authority)
            else:
                ans = None
                while ans not in ("y", "n"):
                    user_output()
                    ans = user_input("Are you sure all listed objects above should be deleted? (y/n)\n")
                    if ans == "y":
                        self._delete(school_authority)
                    elif ans == "n":
                        user_output("Aborted by user.")
                    else:
                        user_output("Please answer 'y' or 'n'.")
        else:
            user_output(f"\nSchool authority {self.school_authority_name} not found. Nothing to do.")


def parse_cmdline():
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=(
            "Removes a school authority, including all associated schools "
            "and the associated user for the provisioning API. This script "
            "is only intended to be run on a primary server."
        ),
        epilog=(
            "Example usage:\n\n"
            "remove-school-authority \\\n"
            "\t--keycloak-admin-name admin \\\n"
            "\t--keycloak-admin-realm master \\\n"
            "\t--keycloak-authority-realm ID-Broker \\\n"
            "\t--keycloak-url https://kc.broker.test/auth/ \\\n"
            "\t--school-authority-name Traeger1"
        ),
    )

    parser.add_argument(
        "--keycloak-admin-name",
        required=True,
        help="Username of the administrator of Keycloak.",
    )

    parser.add_argument(
        "--keycloak-admin-password-file",
        help="Filepath to an unencrypted password file. If not provided, a prompt will ask for the password.",
    )

    parser.add_argument(
        "--keycloak-admin-realm",
        required=True,
        help='The realm which the admin user is configured in. This is usually "master".',
    )

    parser.add_argument(
        "--keycloak-authority-realm",
        required=True,
        help="The realm which the school authority Identity Provider is configured in.",
    )

    parser.add_argument(
        "--keycloak-url",
        required=True,
        help="HTTPS URL to authorize against.",
    )

    parser.add_argument(
        "--school-authority-name",
        required=True,
        help="School authority which should be deleted from the ID-Broker.",
    )

    parser.add_argument(
        "--dry-run",
        action="store_true",
        default=None,
        help="When set no changes will be made, just displayed.",
    )

    parser.add_argument(
        "--force",
        action="store_true",
        default=False,
        help="Disables User Input and deletes objects automatically.",
    )
    args = parser.parse_args()

    if args.force and args.dry_run:
        sys.exit("Cannot use both --force and --dry-run flags together")
    else:
        if args.force:
            mode = SchoolAuthCleaner.FORCE_MODE
        elif args.dry_run:
            mode = SchoolAuthCleaner.DRY_RUN_MODE
        else:
            mode = SchoolAuthCleaner.INTERACTIVE_MODE

    if args.keycloak_admin_password_file is None:
        pwd = user_password_input(f"Please provide the password for {args.keycloak_admin_name}: ")
    else:
        with open(args.keycloak_admin_password_file, "r") as pwd_file:
            pwd = pwd_file.readline().strip()

    return {
        "keycloak_config": {
            "server_url": args.keycloak_url,
            "username": args.keycloak_admin_name,
            "password": pwd,
            "realm_name": args.keycloak_authority_realm,
            "user_realm_name": args.keycloak_admin_realm,
        },
        "school_authority_name": args.school_authority_name,
        "mode": mode,
    }


def main():
    server_role = ucr.get("server/role")
    if server_role != "domaincontroller_master":
        sys.exit(
            "This script is only intended to be run on primary servers. "
            f"The current system has role: '{server_role}' "
            "instead of 'domaincontroller_master'."
        )

    school_auth_cleaner_args = parse_cmdline()
    cleaner = SchoolAuthCleaner(**school_auth_cleaner_args)
    cleaner.clean()


if __name__ == "__main__":
    main()
