#!/usr/bin/python3
#
# Univention Portal
#
# SPDX-FileCopyrightText: 2020-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only

import ast
import datetime
import json
import os
from inspect import getfullargspec
from tempfile import NamedTemporaryFile
from textwrap import dedent

import click

from univention.portal import config, get_all_dynamic_classes, get_dynamic_classes
from univention.portal.factory import make_portal
from univention.portal.log import get_logger, setup_logger


portals_json = "/usr/share/univention-portal/portals.json"


def read_portals_json() -> dict:
    try:
        with open(portals_json) as fd:
            return json.load(fd)
    except OSError:
        return {}


def write_portals_json(content: dict):
    with NamedTemporaryFile(mode="w", prefix=os.path.basename(portals_json), dir=os.path.dirname(portals_json), delete=False) as tmp:
        try:
            os.chmod(tmp.name, 0o660)
            json.dump(content, tmp, sort_keys=True, indent=4)
            tmp.flush()
            os.rename(tmp.name, portals_json)
        except OSError:
            os.unlink(tmp.name)
            raise


@click.group()
def cli():
    pass


@cli.command("add-default")
@click.option("--update/--dont-update", default=False)
def add_default(update: bool):
    changed = False
    json_content = read_portals_json()

    # /univention/portal/
    default_name = "default"
    if default_name in json_content:
        if update:
            warn(f"Overwriting existing {default_name}")
            _add_default(default_name, json_content)
            changed = True
        else:
            info(f"{default_name} already exists")
    else:
        _add_default(default_name, json_content)
        changed = True

    if changed:
        write_portals_json(json_content)
        success(f"{portals_json} written")


@cli.command("add-umc-default")
@click.option("--update/--dont-update", default=False)
def add_umc_default(update: bool):
    changed = False
    json_content = read_portals_json()

    # /univention/umc/
    umc_name = "umc"
    if umc_name in json_content:
        if update:
            warn(f"Overwriting existing {umc_name}")
            _add_umc(umc_name, json_content)
            changed = True
        else:
            info(f"{umc_name} already exists")
    else:
        _add_umc(umc_name, json_content)
        changed = True

    if changed:
        write_portals_json(json_content)
        success(f"{portals_json} written")


@cli.command("add-selfservice-default")
@click.option("--update/--dont-update", default=False)
def add_selfservice_default(update: bool):
    changed = False
    json_content = read_portals_json()

    # /univention/selfservice/
    selfservice_name = "selfservice"
    if selfservice_name in json_content:
        if update:
            warn(f"Overwriting existing {selfservice_name}")
            _add_selfservice(selfservice_name, json_content)
            changed = True
        else:
            info(f"{selfservice_name} already exists")
    else:
        _add_selfservice(selfservice_name, json_content)
        changed = True

    if changed:
        write_portals_json(json_content)
        success(f"{portals_json} written")


def _add_default(name: str, json_content: dict):
    portal_def = {
        "class": "Portal",
        "kwargs": {
            "authenticator": {
                "class": "UMCAuthenticator",
                "kwargs": {
                    "group_cache": {
                        "class": "GroupFileCache",
                        "kwargs": {
                            "cache_file": {
                                "type": "static",
                                "value": "/var/cache/univention-portal/groups.json",
                            },
                            "reloader": {
                                "class": "GroupsReloaderLDAP",
                                "kwargs": {
                                    "binddn": {"key": "hostdn", "type": "config"},
                                    "cache_file": {
                                        "type": "static",
                                        "value": "/var/cache/univention-portal/groups.json",
                                    },
                                    "ldap_base": {"key": "ldap_base", "type": "config"},
                                    "ldap_uri": {"key": "ldap_uri", "type": "config"},
                                    "password_file": {
                                        "type": "static",
                                        "value": "/etc/machine.secret",
                                    },
                                },
                                "type": "class",
                            },
                        },
                        "type": "class",
                    },
                    "umc_session_url": {"key": "umc_session_url", "type": "config"},
                    "auth_mode": {"key": "auth_mode", "type": "config"},
                },
                "type": "class",
            },
            "portal_cache": {
                "class": "PortalFileCache",
                "kwargs": {
                    "cache_file": {
                        "type": "static",
                        "value": "/var/cache/univention-portal/portal.json",
                    },
                    "reloader": {
                        "class": "PortalReloaderUDM",
                        "kwargs": {
                            "cache_file": {
                                "type": "static",
                                "value": "/var/cache/univention-portal/portal.json",
                            },
                            "portal_dn": {"key": "default_domain_dn", "type": "config"},
                        },
                        "type": "class",
                    },
                },
                "type": "class",
            },
            "scorer": {"class": "Scorer", "type": "class"},
        },
        "type": "class",
    }
    json_content[name] = portal_def


def _add_umc(name: str, json_content: dict):
    portal_def = {
        "class": "UMCPortal",
        "kwargs": {
            "authenticator": {
                "class": "UMCAuthenticator",
                "kwargs": {
                    "group_cache": {
                        "class": "GroupFileCache",
                        "kwargs": {
                            "cache_file": {
                                "type": "static",
                                "value": "/var/cache/univention-portal/groups.json",
                            },
                            "reloader": {
                                "class": "GroupsReloaderLDAP",
                                "kwargs": {
                                    "binddn": {"key": "hostdn", "type": "config"},
                                    "cache_file": {
                                        "type": "static",
                                        "value": "/var/cache/univention-portal/groups.json",
                                    },
                                    "ldap_base": {"key": "ldap_base", "type": "config"},
                                    "ldap_uri": {"key": "ldap_uri", "type": "config"},
                                    "password_file": {
                                        "type": "static",
                                        "value": "/etc/machine.secret",
                                    },
                                },
                                "type": "class",
                            },
                        },
                        "type": "class",
                    },
                    "umc_session_url": {"key": "umc_session_url", "type": "config"},
                    "auth_mode": {"key": "auth_mode", "type": "config"},
                },
                "type": "class",
            },
            "scorer": {
                "class": "PathScorer",
                "kwargs": {
                    "path": {"value": "/univention/umc", "type": "static"},
                    "fallback_score": {"value": 0.5, "type": "static"},
                },
                "type": "class"},
        },
        "type": "class",
    }
    json_content[name] = portal_def


def _add_selfservice(name: str, json_content: dict):
    portal_def = {
        "class": "Portal",
        "kwargs": {
            "authenticator": {
                "class": "UMCAuthenticator",
                "kwargs": {
                    "group_cache": {
                        "class": "GroupFileCache",
                        "kwargs": {
                            "cache_file": {
                                "type": "static",
                                "value": "/var/cache/univention-portal/groups.json",
                            },
                        },
                        "type": "class",
                    },
                    "umc_session_url": {"key": "umc_session_url", "type": "config"},
                    "auth_mode": {"key": "auth_mode", "type": "config"},
                },
                "type": "class",
            },
            "portal_cache": {
                "class": "PortalFileCache",
                "kwargs": {
                    "cache_file": {
                        "type": "static",
                        "value": "/var/cache/univention-portal/selfservice.json",
                    },
                    "reloader": {
                        "class": "PortalReloaderUDM",
                        "kwargs": {
                            "cache_file": {
                                "type": "static",
                                "value": "/var/cache/univention-portal/selfservice.json",
                            },
                            "portal_dn": {"key": "selfservice_portal_dn", "type": "config"},
                        },
                        "type": "class",
                    },
                },
                "type": "class",
            },
            "scorer": {
                "class": "PathScorer",
                "kwargs": {
                    "path": {"value": "/univention/selfservice", "type": "static"},
                    "fallback_score": {"value": 0.5, "type": "static"},
                },
                "type": "class"},
        },
        "type": "class",
    }
    json_content[name] = portal_def


@cli.command()
@click.argument("name")
@click.option("--update/--dont-update", default=True)
def add(name: str, update: bool):
    json_content = read_portals_json()
    if name in json_content:
        if update:
            warn(f"Overwriting existing {name}")
        else:
            info(f"{name} already exists")
            return
    click.echo("We will now create a new portal object together")
    click.echo("Which class do you want it to be? Possible answers are:")
    possible_classes = [klass.__name__ for klass in get_all_dynamic_classes()]
    for klass in possible_classes:
        click.echo(f"  {klass}()")
    click.echo("  value")
    click.echo("  config")
    klass_default = None
    if "Portal" in possible_classes:
        klass_default = "Portal()"
    portal_def = ask_value(name, klass_default=klass_default)
    json_content[name] = portal_def
    write_portals_json(json_content)
    success(f"{portals_json} written")
    info(f"You may want to 'push {name}' now")


def ask_value(name: str, klass_default: str | None = None, value_default: str | None = None) -> dict:
    possible_classes = [klass.__name__ for klass in get_all_dynamic_classes()]
    choice = click.prompt(
        f"Choose the value of {name}",
        default=klass_default,
        type=click.Choice([klass + "()" for klass in possible_classes] + ["value", "config"]),
    )
    if choice == "value":
        while True:
            value = click.prompt(
                'Choose a native value (e.g, None, True, 10, "name")', default=value_default,
            )
            print(value_default)
            print(value)
            try:
                return {"type": "static", "value": ast.literal_eval(value)}
            except SyntaxError:
                click.echo(click.style(f"Cannot parse {value}", fg="yellow"))
    elif choice == "config":
        value = click.prompt("Choose a config key from /usr/share/univention-portal/config.json")
        return {"type": "config", "key": value}
    else:
        klass_name = choice[:-2]
        klass = get_dynamic_classes(klass_name)
        click.echo(f"Okay, got class {klass_name}")
        kwargs = {}
        try:
            spec = getfullargspec(klass.__init__)
        except TypeError:
            # __init__ not defined
            pass
        else:
            click.echo(
                "A {} takes {} arguments ({})".format(
                    klass_name, len(spec.args) - 1, ", ".join(repr(arg) for arg in spec.args[1:]),
                ),
            )
            if spec.defaults:
                defaults = dict(
                    zip(spec.args[len(spec.args) - len(spec.defaults):], spec.defaults),
                )
            else:
                defaults = {}
            for arg in spec.args[1:]:
                klass_default = value_default = None
                if arg in defaults:
                    klass_default = "value"
                    value_default = repr(defaults[arg])
                elif camelcase(arg) in possible_classes:
                    klass_default = camelcase(arg) + "()"
                kwargs[arg] = ask_value(
                    arg, klass_default=klass_default, value_default=value_default,
                )
        click.echo(click.style(f"Okay, {klass_name} initialized", fg="green"))
        ret = {"type": "class", "class": klass.__name__}
        if kwargs:
            ret["kwargs"] = kwargs
        return ret


def capfirst(value: str) -> str | None:
    if value:
        return value[0].upper() + value[1:]


def camelcase(value: str) -> str | None:
    if value:
        return "".join(capfirst(part) for part in value.split("_"))


@cli.command()
@click.argument("name")
@click.option("--purge", default=False)
def remove(name: str, purge: bool):
    json_content = read_portals_json()
    if not json_content.pop(name, None):
        warn(f"{name} does not exist in config file")
        return
    obj = get_obj(name)
    if not obj:
        warn(f"{name} does not exist in database")
    else:
        rm_localhost(obj)
        if purge and not any(meta.startswith("server:") for meta in obj.props.meta):
            obj.delete()
            success("Removed unused {} from database")
        else:
            obj.save()
    write_portals_json(json_content)
    success(f"{name} removed")


@cli.command("list")
def list_portals():
    json_content = read_portals_json()
    for name, portal_def in json_content.items():
        click.echo(f"{name}:")
        portal = make_obj(portal_def)
        click.echo(f"  {portal!r}")


@cli.command()
@click.argument("name")
def push(name: str):
    from univention.udm import UDM, NoObject
    from univention.udm.encoders import Base64Bzip2BinaryProperty

    json_content = read_portals_json()
    if name not in json_content:
        warn(f"{name} does not exist in config file")
        return
    portal_def = json_content[name]
    udm = UDM.machine().version(1)
    data = udm.get("settings/data")
    base = "cn=config,cn=portals,cn=univention,{}".format(config.fetch("ldap_base"))
    try:
        obj = data.get(f"cn={name},{base}")
    except NoObject:
        obj = data.new(superordinate="cn=univention,{}".format(config.fetch("ldap_base")))
        obj.position = base
        obj.props.name = name
        obj.props.data_type = "portals/config"
        info("Creating a new settings/data object")
    json_data = json.dumps(portal_def)
    obj.props.data = Base64Bzip2BinaryProperty("data", raw_value=json_data)
    add_localhost(obj)
    obj.save()
    success(f"Saved {name} in {obj.dn}")


@cli.command()
@click.argument("name")
def pull(name: str):
    obj = get_obj(name)
    if not obj:
        warn(f"{name} does not exist in database")
        return
    json_data = json.loads(obj.props.data.raw)
    json_content = read_portals_json()
    json_content[name] = json_data
    write_portals_json(json_content)
    success(f"{portals_json} updated")
    if add_localhost(obj):
        obj.save()
        success(f"{obj.dn} updated")


def get_obj(name: str) -> str | None:
    from univention.udm import UDM, ConnectionError, NoObject  # noqa: A004

    try:
        udm = UDM.machine().version(1)
    except ConnectionError as exc:
        warn(str(exc))
        return None
    data = udm.get("settings/data")
    base = "cn=config,cn=portals,cn=univention,{}".format(config.fetch("ldap_base"))
    dn = f"cn={name},{base}"
    try:
        return data.get(dn)
    except NoObject:
        return None


def add_localhost(obj) -> bool | None:
    localhost = config.fetch("fqdn")
    server_key = f"server:{localhost}"
    if server_key not in obj.props.meta:
        obj.props.meta.append(server_key)
        return True


def rm_localhost(obj) -> bool | None:
    localhost = config.fetch("fqdn")
    server_key = f"server:{localhost}"
    if server_key in obj.props.meta:
        obj.props.meta.remove(server_key)
        return True


@cli.command()
@click.argument("name", nargs=-1)
@click.option("--reason", default="force")
def update(name: str, reason: str):
    json_content = read_portals_json()
    if not name:
        name = json_content.keys()
    for _name in name:
        info(f"Updating {_name}")
        try:
            portal_def = json_content[_name]
        except KeyError:
            warn(f"{name} does not exist in config file")
        else:
            portal_obj = make_portal(portal_def)
            start = datetime.datetime.now()
            if portal_obj.refresh(reason=reason):
                delta = datetime.datetime.now() - start
                success(f"Portal data updated in {delta.total_seconds():.2f}s")
            else:
                info("Portal data untouched")


@cli.command("list-extensions")
def list_extensions():
    for extension in get_all_dynamic_classes():
        print(extension.__name__)
        if extension.__doc__:
            if extension.__doc__[0] == "\n":
                doc = extension.__doc__[1:]
            else:
                doc = extension.__doc__
            print("  " + "\n  ".join(dedent(doc).splitlines()))
            print("")


class SomeObj:
    def __init__(self, klass_name: str, args, kwargs):
        self.klass_name = klass_name
        self.args = args
        self.kwargs = kwargs

    def all_args(self) -> str:
        ret = []
        for arg in self.args:
            ret.append(repr(arg))
        for name, arg in self.kwargs.items():
            ret.append(f"{name}={arg!r}")
        return ", ".join(ret)

    def __repr__(self) -> str:
        return f"{self.klass_name}({self.all_args()})"


def make_obj(obj_def: dict) -> str | SomeObj:
    arg_type = obj_def["type"]
    if arg_type == "static":
        return obj_def["value"]
    elif arg_type == "config":
        return config.fetch(obj_def["key"])
    if arg_type == "class":
        args = []
        kwargs = {}
        for _arg_definition in obj_def.get("args", []):
            args.append(make_obj(_arg_definition))
        for name, _arg_definition in obj_def.get("kwargs", {}).items():
            kwargs[name] = make_obj(_arg_definition)
        return SomeObj(obj_def["class"], args, kwargs)
    raise TypeError(f"Unknown obj_def: {obj_def!r}")


def warn(msg: str):
    get_logger("cli").warning(msg)
    click.echo(click.style(msg, fg="yellow"))


def info(msg: str):
    get_logger("cli").info(msg)
    click.echo(msg)


def success(msg: str):
    get_logger("cli").info(msg)
    click.echo(click.style(msg, fg="green"))


if __name__ == "__main__":
    setup_logger(stream=False)
    cli()
