#!/usr/libexec/platform-python
##################################################################
#
# Main twopence provisioner script
#
# Copyright (C) 2021 Olaf Kirch <okir@suse.de>
#
##################################################################

import sys
import os
import twopence

from twopence import ConfigError
from twopence.provision import *
from twopence.provision.persist import TopologyStatus

class BasicAction(object):
	def __init__(self, args):
		self.args = args
		self._backend = None
		self._config = None

	def loadEmptyConfig(self):
		if self._config is None:
			config = Config(None)
			for path in twopence.global_config_files:
				config.load(path)
			config.addDirectory(twopence.user_config_dir)
			self._config = config

		return self._config

class Action(BasicAction):
	def __init__(self, args):
		super().__init__(args)

		if args.workspace is None:
			print("Error: missing --workspace option")
			parser.print_help()
			exit(2)

		self.workspace = args.workspace
		self.auto_destroy = False

		self._bom = None

		if args.update_images and args.no_update_images:
			raise ValueError("--update-images and --no-update-images are mutually exclusive")

		if args.update_images:
			self._auto_update_images = True
		if args.no_update_images:
			self._auto_update_images = False
		else:
			self._auto_update_images = None

	def error(self, msg):
		print("Error: " + msg, file = sys.stderr)

	def fatal(self, msg):
		print("FATAL: " + msg, file = sys.stderr)
		exit(1)

	@property
	def bom(self):
		if self._bom is None:
			bom = BOM(self.workspace)
			if not bom.load():
				raise ValueError("Failed to load BOM from %s" % bom.path)

			self._bom = bom
		return self._bom

	@property
	def backend(self):
		if self._backend is None:
			self.setBackend(self.bom.backend)
		return self._backend

	def setBackend(self, name):
		self._backend = Backend.create(name)

		# overwrite backend specific default if this was given on the command line
		if self._auto_update_images is not None:
			self._backend.auto_update = self._auto_update_images

		return self._backend

	def loadConfig(self, purpose = None):
		if self._config:
			return self._config

		bom = self.bom

		config = Config(bom.workspace)
		config.status = bom.status
		config.logspace = bom.logspace

		for path in twopence.global_config_files:
			config.load(path)

		# Note: we load global config files first; THEN
		# we add user directories to the config search path.
		config.addDirectory(twopence.user_config_dir)

		for path in bom.config:
			config.load(path)

		if purpose:
			config.validate(purpose = purpose)

		config.requirementsManager = RequirementsPrompter(config.requirementsCatalog)

		self._config = config
		return config

	def validateConfig(self, config, purpose):
		config.validate(purpose = purpose)

	def createTopology(self, purpose = None):
		backend = self.backend
		config = self.loadConfig(purpose)

		config.validate(purpose = purpose)
		if config.backend:
			self.setBackend(config.backend)
		backend = self.backend

		return TestTopology(backend, config = config, workspace = self.workspace)

	def provisionTopology(self, topology):
		if topology.detect():
			print("One or more VMs seem to exist.")
			for instance in topology.instances:
				print("  %s" % instance.name)

			if not self.auto_destroy:
				self.fatal("Please destroy these instances first, or invoke this command with --auto-destroy")

			verbose("Auto-destroying these instances NOW")
			self.shutdownTopology(topology)
			self.destroyTopology(topology)

		self.displayTopology(topology)

		if not topology.prepare():
			self.fatal("Unable to prepare all instances")

		if not topology.start():
			self.fatal("Unable to start instance(s)")

	def displayTopology(self, topology):
		print()
		for ic in topology.instanceConfigs:
			print(f"Node {ic.name}")
			print(f"  Role     {ic.role}")

			platform = ic.platform
			print(f"  Vendor   {platform.vendor}")
			print(f"  OS       {platform.os}")
			print(f"  Platform {platform.name}")
			if platform.features:
				print(f"  Features {', '.join(platform.features)}")
			if platform.requires:
				print(f"  Requires {', '.join(platform.requires)}")
			#print(f"  Image    {ic.image}")
			print(f"  Keyfile  {ic.keyfile}")

			displayedSome = False
			for repo in ic.repositories:
				if platform.repositoryIsActive(repo):
					continue
				if not displayedSome:
					print()
					print("  Activating the following repositories:")
					displayedSome = True
				print(f"    {repo.name:15} {repo.url}")

			if ic.install:
				print()
				print("  Installing the following packages:")
				for name in ic.install:
					print(f"    {name}")

			if ic.start:
				print()
				print("  Starting the following service:")
				for name in ic.start:
					print(f"    {name}")

			# this is a bit whacky.
			ic.cookedStages()

			if ic.stages:
				print()
				print(f"  Provisioning recipes:")
			for stage in ic.stages:
				print(f"    Stage {stage.name} reboot={stage.reboot}")
				for name in stage.run:
					print(f"      run {stage.category}/{name}")
				for invocation in stage.invocations:
					print(f"      {invocation.command}")
					if invocation.path:
						print(f"        (script sourced from {invocation.path})")

			print()

	def shutdownTopology(self, topology):
		verbose("Stopping all instances")
		topology.stop()
		topology.detect()
		if topology.hasRunningInstances():
			self.fatal("Cannot stop all VMs - giving up")

	def destroyTopology(self, topology):
		topology.destroy()
		topology.cleanup()

	def zapWorkspace(self, force = False):
		import os
		import shutil

		bom = self.bom

		empty = True
		for entry in os.scandir(args.workspace):
			if entry.path != bom.path:
				if not force:
					if entry.is_dir():
						self.error("workspace contains directory %s" % entry.path)
					else:
						self.error("workspace contains file %s" % entry.path)
					empty = False
				else:
					if entry.is_dir():
						verbose("Removing directory %s" % entry.path)
						shutil.rmtree(entry.path)
					else:
						verbose("Removing file %s" % entry.path)
						os.remove(entry.path)

		if not empty:
			self.fatal("cannot zap workspace, not empty")

		verbose("Removing %s" % bom.path)
		bom.remove()
		os.rmdir(args.workspace)
		verbose("Removed workspace %s" % args.workspace)

class ActionHelp(Action):
	def perform(self):
		parser.print_help()
		exit(0)

class ActionInit(Action):
	def __init__(self, args):
		super().__init__(args)

		self._backend = args.backend
		self._logspace = args.logspace
		self._configs = args.config

	def perform(self):
		bom = BOM(self.workspace)
		if bom.exists:
			self.fatal("BOM file %s seems to exist, refusing to initialize" % bom.path)
		self._bom = bom

		bom.backend = self._backend
		bom.logspace = self._logspace
		for path in self._configs:
			bom.addConfig(path)

		bom.save()

		try:
			config = self.loadConfig(purpose = "init")
		except ConfigError as e:
			print("Error: the resulting configuration is not valid")
			print(e)
			exit(2)

		return

class ActionCreate(Action):
	def __init__(self, args):
		super().__init__(args)
		self.auto_destroy = args.auto_destroy

	def perform(self):
		topology = self.createTopology(purpose = "testing")
		self.provisionTopology(topology)

class ActionPackage(Action):
	def __init__(self, args):
		super().__init__(args)

		if not args.target:
			twopence.fatal("Missing argument: name of target build")
		self.target = args.target

	def perform(self):
		topology = self.createTopology(purpose = "destroy")
		if not topology.detect():
			twopence.fatal("No nodes found - I can't package thin air")

		if len(topology.instances) != 1:
			twopence.fatal(f"I can only package a single node, not {len(topology.instances)}")
		buildNode = topology.instances[0]

		print(f"Found build instance {buildNode.name}, will be packaged as {self.target}")

		self.shutdownTopology(topology)

		# Now package up the image and all the ancillary information, like
		# ssh keys and the platform .config file describing the result.
		topology.package(buildNode.name, self.target)

class ActionDestroy(Action):
	def __init__(self, args):
		super().__init__(args)
		self.zap = args.zap
		self.force = args.force

	def perform(self):
		topology = self.createTopology(purpose = "destroy")
		if not topology.detect():
			verbose("Nothing to destroy")
		else:
			print("Detected running instance(s):")
			for instance in topology.instances:
				print("  %s" % instance.name)

			if not self.force:
				self.shutdownTopology(topology)

		self.destroyTopology(topology)

		if self.zap:
			self.zapWorkspace(force = self.force)

class ActionStatus(Action):
	def perform(self):
		topology = self.createTopology(purpose = None)
		if not topology.detect(detectNetwork = True):
			verbose("No instances")
		else:
			print("Detected instance(s) for testcase %s:" % topology.testcase)
			for instance in topology.instances:
				addresses = []
				for nif in instance.networkInterfaces:
					addresses.append(str(nif))

				if addresses:
					address = addresses.pop(0)
				else:
					address = ''

				print("  %-20s %-10s %s" % (instance.name,
						instance.running and "running" or "stopped",
						address))

				while addresses:
					address = addresses.pop(0)
					print(" %*.32s %s" % ('', address))

class ActionShow(Action):
	def __init__(self, args):
		super().__init__(args)

		self.info_types = args.info_type

	def perform(self):
		topology = self.createTopology(purpose = None)
		for type in self.info_types:
			if type == 'status-file':
				st = topology.persistentState
				print(st.path or "")
			else:
				raise ValueError("Unsupported info-type \"%s\"" % type)

class ActionLogin(Action):
	def __init__(self, args):
		super().__init__(args)

		if len(args.node) != 1:
			raise ValueError("Unexpected number of arguments")

		self.node = args.node[0]
		self.use_ipv4 = None
		self.use_ipv6 = None

	def perform(self):
		config = self.loadConfig(purpose = "login")

		print(config.status)
		if not config.status:
			raise ValueError("no status.conf file found in workspace %s", args.workspace)

		status = TopologyStatus(config.status)
		node = status.getNodeState(self.node)

		# We should really use nsenter here.
		if status.backendName == 'vagrant':
			# The backend should really store the name of the default
			# account in status.conf
			self.performSSH(node, user = 'vagrant')
		elif status.backendName == 'podman':
			self.performNsEnter(node)
		else:
			raise ValueError(f"Cannot login to a container handled by backend {status.backendName}")

	def performSSH(self, node, user):
		if self.use_ipv4:
			address = node.ipv4_address
		elif self.use_ipv6:
			address = node.ipv6_address
		else:
			address = node.ipv4_address or node.ipv6_address

		if not user:
			raise ValueError("Unable to determine login user name")

		command = ["ssh",
			# Do not pollute the user's .known_hosts file with address/key pairs that
			# will change all the time, anyway
			"-o", "UserKnownHostsFile=/dev/null",
			# And don't ask about saving host keys to /dev/null ...
			"-o", "StrictHostKeyChecking=no",
			]
		if node.keyfile:
			command += ["-i", node.keyfile]

		command.append("%s@%s" % (user, address))
		self.doit(address, command)

	def performNsEnter(self, node):
		container = node.container
		if not container.pid:
			raise ValueError(f"Cannot log into {node.name}: no container pid provided")

		command = f"nsenter -a -t {container.pid}"
		self.doit(container.name, command, sudo = True)

	def doit(self, name, command, sudo = False):
		if type(command) == list:
			command = " ".join(command)

		if sudo:
			command = "sudo " + command

		print("Now connect to %s" % name)
		print("  %s" % command)
		os.system(command)
		print("Done.")

class ActionPlatform(BasicAction):
	def __init__(self, args):
		super().__init__(args)
		self._requirements = {}

	def perform(self):
		config = self.loadEmptyConfig()

		all = list(config.locateBuildTargets())
		all = sorted(all, key = lambda ctx: (ctx.platform.vendor, ctx.platform.os, ctx.platform.name))

		if not all:
			print("No platforms configured. Something is wrong.")
			return

		requires = set()
		backends = {}

		print()
		for ctx in all:
			platform = ctx.platform

			print("%s (%s)" % (platform.name, ctx.path))
			print("    Vendor/OS:      %s/%s" % (platform.vendor, platform.os))
			if platform.build_time:
				print("    Build time:     %s" % platform.build_time)
			self.showRequires(config, platform)
			if platform.features:
				print("    Features:       %s" % ", ".join(platform.features))
			if platform.install:
				print("    Install pkgs:   %s" % ", ".join(platform.install))
			if platform.start:
				print("    Start svcs:     %s" % ", ".join(platform.start))

			appliedOptions = platform.applied_build_options
			if appliedOptions:
				print("    Built with:     %s" % ", ".join(appliedOptions))

			if ctx.builds:
				print()
				print("    Build options")
			for build in ctx.builds:
				extra = ""
				if build.name in appliedOptions:
					extra = " (already applied)"
				print(f"      - {build.name}{extra}")
				if build.features:
					print("        Provides:       %s" % ", ".join(build.features))
				if build.requires:
					print("    Requires:       %s" % ", ".join(build.requires))
				for name in platform.requires:
					requires.add(name)

			if platform.backends:
				# We could print per-backend image/key information here
				# If we want to get super smart, we could even display image version
				# (upstream and local).
				print()
				print("    Backends")
				for backendInfo in platform.backends.values():
					backend = backends.get(backendInfo.name)
					if backend is None:
						backend = Backend.create(backendInfo.name)
						backends[backendInfo.name] = backend
					print("      - %s" % backend.name)
					for name, value in backend.renderPlatformInformation(backendInfo):
						print("        %-12s %s" % (name.capitalize(), value))

			for name in platform.requires:
				requires.add(name)

			print()

		if requires:
			print()
			print("Detected requirement(s): %s" % ", ".join(requires))
			for name in requires:
				path = config.locateConfig("%s.conf" % name)
				if path is None:
					print("  %s: not provided by any config file; please create %s/%s.conf" % (
							name, twopence.user_data_dir, name))
				else:
					print("  %s: provided by %s" % (name, path))

	# obj is Platform or Build
	def showRequires(self, config, obj):
		if obj.requires:
			print("    Requires:       %s" % ", ".join(obj.requires))
			for name in obj.requires:
				status = self._requirements.get(name)
				if status is None:
					path = config.locateConfig("%s.conf" % name)
					if path is None:
						status = "not provided by any config file; please create %s/%s.conf" % (
								twopence.user_data_dir, name)
					else:
						status = "provided by %s" % (path)
					self._requirements[name] = status
				print("    - %13s %s" % (name, status))

# FIXME: It might make sense to put the history loading into a utility class
# and share it across all of twopence/susetest
import readline
import atexit

class RequirementsPrompter(RequirementsManager):
	histfile = None
	h_len = None

	def __init__(self, config):
		super().__init__(config)

		self.loadHistory("~/.twopence/reqhistory")

	@classmethod
	def loadHistory(cls, path):
		if cls.h_len is not None:
			return

		cls.histfile = os.path.expanduser(path)
		try:
			readline.read_history_file(cls.histfile)
			cls.h_len = readline.get_current_history_length()
		except FileNotFoundError:
			open(cls.histfile, 'wb').close()
			cls.h_len = 0

		atexit.register(cls.saveHistory)

	@classmethod
	def saveHistory(cls):
		new_h_len = readline.get_current_history_length()
		readline.set_history_length(1000)
		readline.append_history_file(new_h_len - cls.h_len, cls.histfile)

	def prompt(self, nodeName, req):
		print("Node %s requires %s" % (nodeName, req.name))
		result = {}
		for name, prompt, default in req.prompt():
			if default:
				prompt += " [%s]" % default
			prompt += ": "

			try:
				response = input(prompt)
			except EOFError:
				print("<EOF>")
				print("Aborted by user")
				exit(42)

			response = response.strip()
			if not response and default is not None:
				response = default

			result[name] = response

		return result

def build_arg_parser():
	import argparse

	parser = argparse.ArgumentParser(description = 'Provision test instances.')
	parser.add_argument('--workspace',
			    help = 'the directory to use as workspace')
	parser.add_argument('--quiet', default = False, action = 'store_true',
			    help = 'Disable most output')

	parser.add_argument('--update-images', default = False, action = 'store_true',
			    help = 'Automatically update images to their latest available version (eg from vagrantcloud, or a container registry)')
	parser.add_argument('--no-update-images', default = False, action = 'store_true',
			    help = 'Do not update images to their latest available version')

	parser.add_argument('--status',
			    help = 'path to the file to store status info [default $workspace/status.conf]')
	parser.add_argument('--debug', default = False, action = 'store_true',
			    help = 'Enable debug output')
	parser.add_argument('--debug-schema', default = False, action = 'store_true',
			    help = 'Enable debugging for config schema')

	sub = parser.add_subparsers(dest = 'command')

	sp = sub.add_parser('init', help = 'Initialize a run')
	sp.set_defaults(action = ActionInit)
	sp.add_argument('--config', action = 'append', default = [],
			help='path to the file describing the nodes to be provisioned')
	sp.add_argument('--backend', default = 'vagrant',
			help = 'the provisioning backend to use [vagrant]')
	sp.add_argument('--logspace',
			help = 'the default location for test runs to write their log files to')

	sp = sub.add_parser('create', help = 'Create the requested nodes')
	sp.set_defaults(action = ActionCreate)
	sp.add_argument('--auto-destroy', default = False, action = 'store_true',
			help = 'if one or more of the requested nodes are running, destroy them')

	sp = sub.add_parser('package', help = 'Shutdown the running topology and package it as a new twopence platform')
	sp.set_defaults(action = ActionPackage)
	sp.add_argument('target', metavar='TARGET', nargs='?',
			help = 'name of the target image to be built')

	sp = sub.add_parser('destroy', help = 'Destroy the topology')
	sp.set_defaults(action = ActionDestroy)
	sp.add_argument('--zap', default = False, action = 'store_true',
			help = 'Remove the workspace as well, empty')
	sp.add_argument('--force', default = False, action = 'store_true',
			help = 'If used with --zap, remove the workspace and everything it contains')

	sp = sub.add_parser('status', help = 'Display status on the topology')
	sp.set_defaults(action = ActionStatus)

	sp = sub.add_parser('show', help = 'Display info on the test case')
	sp.set_defaults(action = ActionShow)
	sp.add_argument('info_type', metavar='INFO-TYPE', nargs = '+',
			help='info type to display (status-file, ...)')

	sp = sub.add_parser('login', help = 'Log into the indicated node')
	sp.set_defaults(action = ActionLogin)
	sp.add_argument('node', metavar='NODE-NAME', nargs = 1,
			help='the node name of the system to connect to')

	sp = sub.add_parser('platforms', help = 'Display information on available platforms')
	sp.set_defaults(action = ActionPlatform)

	sp = sub.add_parser('help', help = 'Print this message')
	sp.set_defaults(action = ActionHelp)

	return parser


parser = build_arg_parser()
args = parser.parse_args()

if args.debug_schema:
	from twopence.provision.config import Schema
	Schema.debug.enabled = True
	args.debug = True

if args.debug:
	logger.enableLogLevel('debug')
elif not args.quiet:
	logger.disableLogLevel('verbose')

if not args.command:
	error("missing subcommand")
	parser.print_help()
	exit(2)

action = args.action(args)
action.perform()
