# SPDX-License-Identifier: LicenseRef-CSSL-1.0

#---------------------------------------------------------------------
#
#   Required Python Version
#     Python 3.x
#
#---------------------------------------------------------------------

import abc
import logging
import subprocess as sp
import os
import paramiko
import uuid
import time

SSHTIMEOUT=7

def is_local(host):
	return host is None or host.lower() in ["", "none", "localhost"]

# helper that returns either LocalCmd or RemoteCmd based on passed host name
def getConnection(host, d=None):
	if is_local(host):
		return LocalCmd(d=d)
	else:
		return RemoteCmd(host, d=d)

# provides a partial interface for the legacy SSHconnection class (getBefore(), command())
class Cmd(metaclass=abc.ABCMeta):
	def cd(self, d, silent=False):
		if d == None or d == '':
			self.cwd = None
		elif d[0] == '/':
			self.cwd = d
		else:
			if not self.cwd:
				# no cwd set: get current working directory
				self.cwd = self.run('pwd').stdout.strip()
			self.cwd += f"/{d}"
		if not silent:
			logging.debug(f'cd {self.cwd}')

	@abc.abstractmethod
	def exec_script(path, timeout, parameters=None, redirect=None, silent=False):
		return

	@abc.abstractmethod
	def run(self, line, timeout=300, silent=False):
		return

	def command(self, commandline, expectedline=None, timeout=300, silent=False, resync=False):
		splitted = commandline.split(' ')
		if splitted[0] == 'cd':
			self.cd(' '.join(splitted[1:]), silent)
		else:
			self.run(commandline, timeout, silent)
		return 0

	@abc.abstractmethod
	def close(self):
		return

	@abc.abstractmethod
	def getBefore(self):
		return

	@abc.abstractmethod
	def copyin(self, scpIp, scpUser, scpPw, src, tgt):
		return

	@abc.abstractmethod
	def copyout(self, scpIp, scpUser, scpPw, src, tgt):
		return

class LocalCmd(Cmd):
	def __enter__(self):
		return self

	def __exit__(self, exc_type, exc_value, exc_traceback):
		self.close()

	def __init__(self, d = None):
		self.cwd = d
		if self.cwd is not None:
			logging.debug(f'Working dir is {self.cwd}')
		self.cp = sp.CompletedProcess(args='', returncode=0, stdout='')

	def exec_script(self, path, timeout, parameters=None, redirect=None, silent=False):
		if redirect and not redirect.startswith("/"):
			raise ValueError(f"redirect must be absolute, but is {redirect}")
		c = f"{path} {parameters}" if parameters else path
		if not redirect:
			ret = sp.run(c, shell=True, cwd=self.cwd, timeout=timeout, stdout=sp.PIPE, stderr=sp.STDOUT)
			ret.stdout = ret.stdout.decode('utf-8').strip()
		else:
			with open(redirect, "w") as f:
				ret = sp.run(c, shell=True, timeout=timeout, stdout=f, stderr=f)
			ret.args += f" &> {redirect}"
			ret.stdout = ""
		if not silent:
			logging.debug(f"local> {ret.args}")
		return ret

	def run(self, line, timeout=300, silent=False, reportNonZero=True):
		if not silent:
			logging.debug(f"local> {line}")
		try:
			if line.strip().endswith('&'):
				# if we wait for stdout, subprocess does not return before the end of the command
				# however, we don't want to wait for commands with &, so just return fake command
				ret = sp.run(line, shell=True, cwd=self.cwd, timeout=5, executable='/bin/bash')
				time.sleep(0.1)
			else:
				ret = sp.run(line, shell=True, cwd=self.cwd, stdout=sp.PIPE, stderr=sp.STDOUT, timeout=timeout, executable='/bin/bash')
		except Exception as e:
			ret = sp.CompletedProcess(args=line, returncode=255, stdout=f'Exception: {str(e)}'.encode('utf-8'))
		if ret.stdout is None:
			ret.stdout = b''
		ret.stdout = ret.stdout.decode('utf-8').strip()
		if reportNonZero and ret.returncode != 0:
			logging.warning(f'command "{ret.args}" returned non-zero returncode {ret.returncode}: output:\n{ret.stdout}')
		self.cp = ret
		return ret

	def close(self):
		pass

	def getBefore(self):
		return self.cp.stdout

	def copyin(self, src, tgt, recursive=False):
		if src[0] != '/' or tgt[0] != '/':
			raise Exception(f'support only absolute file paths (src {src} tgt {tgt})!')
		if src == tgt:
			return # nothing to copy, file is already where it should go
		opt = '-r' if recursive else ''
		return self.run(f'cp {opt} {src} {tgt}').returncode == 0

	def copyout(self, src, tgt, recursive=False):
		return self.copyin(src, tgt, recursive)

def PutFile(client, src, tgt):
	success = True
	sftp = client.open_sftp()
	try:
		sftp.put(src, tgt)
	except FileNotFoundError as error:
		logging.error(f"error while putting {src}: {error}")
		success = False
	sftp.close()
	return success

def GetFile(client, src, tgt):
	success = True
	sftp = client.open_sftp()
	try:
		sftp.get(src, tgt)
	except FileNotFoundError as error:
		logging.error(f"error while getting {src}: {error}")
		success = False
	sftp.close()
	return success

class RemoteCmd(Cmd):
	def __enter__(self):
		return self

	def __exit__(self, exc_type, exc_value, exc_traceback):
		self.close()

	def __init__(self, hostname, d=None):
		cIdx = 0
		self.hostname = hostname
		self.client = RemoteCmd._ssh_init()
		cfg = RemoteCmd._lookup_ssh_config(hostname)
		self.cwd = d
		self.cp = sp.CompletedProcess(args='', returncode=0, stdout='')
		while cIdx < 3:
			try:
				self.client.connect(**cfg)
				return
			except:
				logging.error(f'Could not connect to {hostname}, tried for {cIdx} time')
				cIdx +=1
		raise Exception ("Error: max retries, did not connect to host")

	def _ssh_init():
		logging.getLogger('paramiko').setLevel(logging.ERROR) # prevent spamming through Paramiko
		client = paramiko.SSHClient()
		client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
		return client

	def _lookup_ssh_config(hostname):
		if is_local(hostname):
			raise ValueError("Using localhost as SSH target is not allowed: use LocalCmd instead.")
		ssh_config = paramiko.SSHConfig()
		user_config_file = os.path.expanduser("~/.ssh/config")
		if os.path.exists(user_config_file):
			with open(user_config_file) as f:
				ssh_config.parse(f)
		else:
			raise FileNotFoundError('class needs SSH config at ~/.ssh/config')
		ucfg = ssh_config.lookup(hostname)
		if 'identityfile' not in ucfg or 'user' not in ucfg:
			raise KeyError(f'no identityfile or user in SSH config for host {hostname}')
		cfg = {'hostname':hostname, 'username':ucfg['user'], 'key_filename':ucfg['identityfile'], 'timeout':SSHTIMEOUT}
		if 'hostname' in ucfg:
			cfg['hostname'] = ucfg['hostname'] # override user-given hostname with what is in config
		if 'port' in ucfg:
			cfg['port'] = int(ucfg['port'])
		if 'proxycommand' in ucfg:
			cfg['sock'] = paramiko.ProxyCommand(ucfg['proxycommand'])
		return cfg

	def exec_script(self, path, timeout, parameters=None, redirect=None, silent=False):
		if redirect and not redirect.startswith("/"):
			raise ValueError(f"redirect must be absolute, but is {redirect}")
		p = parameters if parameters else ""
		r = f"> {redirect}" if redirect else ""
		if not silent:
			logging.debug(f"local> ssh {self.hostname} bash -s {p} < {path} {r}")
		bash_opt = 'BASH_XTRACEFD=1' # write bash set -x output to stdout, see bash(1)
		stdin, stdout, stderr = self.client.exec_command(f"{bash_opt} bash -s {p} {r}", timeout=timeout)
		# open() the file f at path, read() it and write() it into the stdin of the bash -s cmd
		with open(path) as f:
			stdin.write(f.read())
			stdin.close()
		cmd = path
		if parameters: cmd += f" {parameters}"
		if redirect: cmd += f" &> {redirect}"
		ret = sp.CompletedProcess(args=cmd, returncode=stdout.channel.recv_exit_status(), stdout=stdout.read(size=None) + stderr.read(size=None))
		ret.stdout = ret.stdout.decode('utf-8').strip()
		return ret

	def run(self, line, timeout=300, silent=False, reportNonZero=True):
		if not silent:
			logging.debug(f"ssh[{self.hostname}]> {line}")
		if self.cwd:
			line = f"cd {self.cwd} && {line}"
		try:
			if line.strip().endswith('&'):
				# if we wait for stdout, Paramiko does not return before the end of the command
				# however, we don't want to wait for commands with &, so just return fake command
				self.client.exec_command(line, timeout = 5)
				ret = sp.CompletedProcess(args=line, returncode=0, stdout=b'')
				time.sleep(0.1)
			else:
				stdin, stdout, stderr = self.client.exec_command(line, timeout=timeout)
				ret = sp.CompletedProcess(args=line, returncode=stdout.channel.recv_exit_status(), stdout=stdout.read(size=None) + stderr.read(size=None))
		except Exception as e:
			ret = sp.CompletedProcess(args=line, returncode=255, stdout=f'Exception: {str(e)}'.encode('utf-8'))
		ret.stdout = ret.stdout.decode('utf-8').strip()
		if reportNonZero and ret.returncode != 0:
			logging.warning(f'command "{line}" returned non-zero returncode {ret.returncode}: output:\n{ret.stdout}')
		self.cp = ret
		return ret

	def close(self):
		self.client.close()

	def getBefore(self):
		return self.cp.stdout

	# if recursive is True, tgt must be a directory (and src is file or directory)
	# if recursive is False, tgt and src must be a file name
	def copyout(self, src, tgt, recursive=False):
		logging.debug(f"copyout: local:{src} -> {self.hostname}:{tgt}")
		if recursive:
			tmpfile = f"{uuid.uuid4()}.tar"
			abstmpfile = f"/tmp/{tmpfile}"
			with LocalCmd() as cmd:
				if cmd.run(f"tar -cf {abstmpfile} {src}").returncode != 0:
					return False
				if not PutFile(self.client, abstmpfile, abstmpfile):
					return False
				cmd.run(f"rm {abstmpfile}")
				ret = self.run(f"mv {abstmpfile} {tgt}; cd {tgt} && tar -xf {tmpfile} && rm {tmpfile}")
				return ret.returncode == 0
		else:
			return PutFile(self.client, src, tgt)

	# if recursive is True, tgt must be a directory (and src is file or directory)
	# if recursive is False, tgt and src must be a file name
	def copyin(self, src, tgt, recursive=False):
		logging.debug(f"copyin: {self.hostname}:{src} -> local:{tgt}")
		if recursive:
			tmpfile = f"{uuid.uuid4()}.tar"
			abstmpfile = f"/tmp/{tmpfile}"
			if self.run(f"tar -cf {abstmpfile} {src}").returncode != 0:
				return False
			if not GetFile(self.client, abstmpfile, abstmpfile):
				return False
			self.run(f"rm {abstmpfile}")
			with LocalCmd() as cmd:
				ret = cmd.run(f"mv {abstmpfile} {tgt}; cd {tgt} && tar -xf {tmpfile} && rm {tmpfile}")
				return ret.returncode == 0
		else:
			return GetFile(self.client, src, tgt)
