#!/usr/bin/python3
#
# Univention Portal
#
# Like what you see? Join us!
# https://www.univention.com/about-us/careers/vacancies/
#
# Copyright 2020-2022 Univention GmbH
#
# https://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
# <https://www.gnu.org/licenses/>.

import os
import json
import datetime
import ast
import inspect
from textwrap import dedent
from typing import Dict, Union

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

import click


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 EnvironmentError:
		return {}


def write_portals_json(content: Dict):
	with open(portals_json, "w") as fd:
		os.chmod(fd.name, 0o660)
		json.dump(content, fd, sort_keys=True, indent=4)


@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("Overwriting existing {}".format(default_name))
			_add_default(default_name, json_content)
			changed = True
		else:
			info("{} already exists".format(default_name))
	else:
		_add_default(default_name, json_content)
		changed = True

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


@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("Overwriting existing {}".format(umc_name))
			_add_umc(umc_name, json_content)
			changed = True
		else:
			info("{} already exists".format(umc_name))
	else:
		_add_umc(umc_name, json_content)
		changed = True

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


@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("Overwriting existing {}".format(selfservice_name))
			_add_selfservice(selfservice_name, json_content)
			changed = True
		else:
			info("{} already exists".format(selfservice_name))
	else:
		_add_selfservice(selfservice_name, json_content)
		changed = True

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


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("Overwriting existing {}".format(name))
		else:
			info("{} already exists".format(name))
			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("  {}()".format(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("{} written".format(portals_json))
	info("You may want to 'push {}' now".format(name))


def ask_value(name: str, klass_default: Union[str, None] = None, value_default: Union[str, None] = None) -> Dict:
	possible_classes = [klass.__name__ for klass in get_all_dynamic_classes()]
	choice = click.prompt(
		"Choose the value of {}".format(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("Cannot parse {}".format(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("Okay, got class {}".format(klass_name))
		kwargs = {}
		try:
			spec = inspect.getargspec(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])
				else:
					if 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("Okay, {} initialized".format(klass_name), fg="green"))
		ret = {"type": "class", "class": klass.__name__}
		if kwargs:
			ret["kwargs"] = kwargs
		return ret


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


def camelcase(value: str) -> Union[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("{} does not exist in config file".format(name))
		return
	obj = get_obj(name)
	if not obj:
		warn("{} does not exist in database".format(name))
	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("{} removed".format(name))


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


@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("{} does not exist in config file".format(name))
		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("cn={},{}".format(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("Saved {} in {}".format(name, obj.dn))


@cli.command()
@click.argument("name")
def pull(name: str):
	obj = get_obj(name)
	if not obj:
		warn("{} does not exist in database".format(name))
		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("{} updated".format(portals_json))
	if add_localhost(obj):
		obj.save()
		success("{} updated".format(obj.dn))


def get_obj(name: str) -> Union[str, None]:
	from univention.udm import UDM, NoObject, ConnectionError

	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 = "cn={},{}".format(name, base)
	try:
		return data.get(dn)
	except NoObject:
		return None


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


def rm_localhost(obj) -> Union[bool, None]:
	localhost = config.fetch("fqdn")
	server_key = "server:{}".format(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("Updating {}".format(_name))
		try:
			portal_def = json_content[_name]
		except KeyError:
			warn("{} does not exist in config file".format(name))
		else:
			portal_obj = make_portal(portal_def)
			start = datetime.datetime.now()
			if portal_obj.refresh(reason=reason):
				delta = datetime.datetime.now() - start
				success("Portal data updated in {:.2f}s".format(delta.total_seconds()))
			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(object):
	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("{}={!r}".format(name, arg))
		return ", ".join(ret)

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


def make_obj(obj_def: Dict) -> Union[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("Unknown obj_def: {!r}".format(obj_def))


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()
