#! /usr/bin/python3

import gi
import json
import os
import shutil
import subprocess
import sys

gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

# Join an array of strings, ignoring any empty strings and separating the parts
# with line breaks.
#
# For example:
#  join_lines(['', 'one\ntwo']) -> 'one\ntwo' # doesn't add an empty line
#  join_lines(['one', 'two\nthree']) -> 'one\ntwo\nthree' # doesn't smash lines together
def join_lines(values):
	return '\n'.join(filter(len, values))

class Client():
	builder = Gtk.Builder()
	confdir = os.path.expanduser("~/.pqccomms-client")
	siteConfig = {}
	cn = ""

	def __init__(self):
		self.builder.add_from_file("/usr/share/pqccomms-client/client.glade")
		self.builder.connect_signals(self)

		self.window = self.builder.get_object("ClientWindow")
		self.window.show_all()

		self.refresh()

	def msgbox(self, msg):
		md = Gtk.MessageDialog(transient_for=self.window, flags=0,
			message_type=Gtk.MessageType.INFO,
			buttons=Gtk.ButtonsType.OK,
			text=msg)
		md.run()
		md.destroy()

	def confirmbox(self, msg):
		md = Gtk.MessageDialog(transient_for=self.window, flags=0,
			message_type=Gtk.MessageType.INFO,
			buttons=Gtk.ButtonsType.YES_NO,
			text=msg)
		response = md.run()
		md.destroy()
		return response == Gtk.ResponseType.YES

	def refresh(self):
		self.siteConfig = {}
		try:
			with open(f'{self.confdir}/site.json', "r") as siteJsonFile:
				self.siteConfig = json.load(siteJsonFile)
		except:
			self.siteConfig = {}

		self.cn = ""
		if "domain" in self.siteConfig:
			try:
				with open(f'{self.confdir}/cn', "r") as cnFile:
					self.cn = cnFile.read()
			except:
				self.cn = ""

		siteLabel = self.builder.get_object("siteSelectionLabel")
		siteLabel.set_text(self.siteConfig.get("domain", "No site configured"))

		cnEntry = self.builder.get_object("clientCommonNameEntry")
		cnEntry.set_text(self.cn)

		openSiteCfgButton = self.builder.get_object("openSiteConfigurationFileButton")
		createPkButton = self.builder.get_object("createPrivateKeyButton")
		authorizeButton = self.builder.get_object("authorizeButton")
		installAppsButton = self.builder.get_object("installAppsButton")
		deleteResetButton = self.builder.get_object("deleteResetButton")

		# Only some actions are available at any given time, depending
		# on the
		#                   [blank]
		#          select site |
		#                      V
		#                 [have site]-. reselect site
		#                    | ^    ^-'
		#   create priv. key | |\delete+reset
		#                    V | '--.
		#                 [have PK] |
		#          authorize |  .---'
		#                    V  |
		#                 [have cert]-. install apps
		#                           ^-'

		# Determine what actions have been performed
		# - none (no site, no CSR)
		# - site selected (site, no CSR)
		# - PK created (site, CSR, but no cert)
		# - authorized (site, CSR and cert)
		if not "domain" in self.siteConfig:
			# Blank.  Can only select a site.
			openSiteCfgButton.set_sensitive(True)
			cnEntry.set_sensitive(False)
			createPkButton.set_sensitive(False)
			authorizeButton.set_sensitive(False)
			installAppsButton.set_sensitive(False)
			deleteResetButton.set_sensitive(False)
		elif self.cn == "" or not os.path.isfile(f'{self.confdir}/pki/reqs/{self.cn}.req'):
			# Have site.  Can create private key or reselect site.
			openSiteCfgButton.set_sensitive(True)
			cnEntry.set_sensitive(True)
			createPkButton.set_sensitive(True)
			authorizeButton.set_sensitive(False)
			installAppsButton.set_sensitive(False)
			deleteResetButton.set_sensitive(False)
		elif not os.path.isfile(f'{self.confdir}/pki/issued/{self.cn}.crt'):
			# Have private key.  Can authorize or delete+reset.
			openSiteCfgButton.set_sensitive(False)
			cnEntry.set_sensitive(False)
			createPkButton.set_sensitive(False)
			authorizeButton.set_sensitive(True)
			installAppsButton.set_sensitive(False)
			deleteResetButton.set_sensitive(True)
		else:
			# Have signed cert.  Can install apps or delete+reset.
			openSiteCfgButton.set_sensitive(False)
			cnEntry.set_sensitive(False)
			createPkButton.set_sensitive(False)
			authorizeButton.set_sensitive(False)
			installAppsButton.set_sensitive(True)
			deleteResetButton.set_sensitive(True)

	def selectNewSiteConfigurationFile(self, path):
		# Install the CA certificate from this config file as root (also
		# replacing any prior certificate if one existed).  If this
		# fails, then don't copy the config, the rest of the setup would
		# not work.
		newSiteConfig = {}
		try:
			with open(path, "r") as newJsonFile:
				newSiteConfig = json.load(newJsonFile)
		except:
			newSiteConfig = {}
		siteCa = newSiteConfig.get("ca")

		if siteCa == None:
			# Shouldn't really happen for a well-formed site config
			# file, just return
			print(f'siteCa: {siteCa}')
			return

		os.makedirs(self.confdir, exist_ok=True)

		caFilePath = f'{self.confdir}/pqccomms-client-ca.crt'
		caFile = open(caFilePath, "w")
		caFile.write(siteCa)
		caFile.close()

		installResult = subprocess.run(['pkexec',
			'install-ca-certificate', caFilePath],
			capture_output=True)
		try:
			os.remove(caFilePath)
		except:
			pass

		if installResult.returncode != 0:
			self.msgbox(join_lines([f'Could not install CA certificate from {path}:',
				installResult.stdout.decode("utf-8"),
				installResult.stderr.decode("utf-8")]))
			return

		# We installed the CA certificate, so select the new site
		shutil.copy(path, f'{self.confdir}/site.json')

	def onOpenSiteConfigurationFilePressed(self, *args):
		# Installed PWAs depend on the site domain, so they must be
		# removed if the site is being changed.  Check if any are
		# installed.
		listInstalledResult = subprocess.run(['pqccomms-pwas', '--list-installed'],
			capture_output=True)
		installedPwas = listInstalledResult.stdout.decode("utf-8")
		if installedPwas != '':
			if not self.confirmbox(f'Changing sites will delete data stored by web applications for the old site.\n\nDelete this data and continue?'):
				return # User cancelled deleting PWAs
			# Uninstall PWAs and delete data, they'll need to be
			# installed again with the new domain
			subprocess.run(['pqccomms-pwas', '--uninstall'])

		dialog = Gtk.FileChooserDialog(
			title="Select the site configuration file",
			parent=self.window, action=Gtk.FileChooserAction.OPEN)
		dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
		     Gtk.STOCK_OPEN, Gtk.ResponseType.OK)

		json_filter = Gtk.FileFilter()
		json_filter.set_name("JSON file")
		json_filter.add_mime_type("application/json")
		dialog.add_filter(json_filter)

		response = dialog.run()
		if response == Gtk.ResponseType.OK:
			self.selectNewSiteConfigurationFile(dialog.get_filename())

		dialog.destroy()
		self.refresh()

	def onCreatePrivateKeyPressed(self, *args):
		cnEntry = self.builder.get_object("clientCommonNameEntry")
		cn = cnEntry.get_text()
		if cn == "":
			# No CN entered
			return

		os.makedirs(self.confdir, exist_ok=True)

		cnFile = open(f'{self.confdir}/cn', "w")
		cnFile.write(cn)
		cnFile.close()

		if not os.path.isdir(f'{self.confdir}/pki'):
			subprocess.run(['/usr/share/easy-rsa/easyrsa', 'init-pki'],
				cwd=self.confdir)

		if not os.path.isfile(f'{self.confdir}/pki/ca.crt'):
			shutil.copy("/usr/local/share/ca-certificates/pqccomms-client-ca.crt",
				f'{self.confdir}/pki/ca.crt')

		# gen-req will prompt for a CN, we have to press 'Enter' to
		# accept the CN provided on the command line.  Sending '\n'
		# as input gets through this, but there will be ioctl errors.
		# easy-rsa isn't meant for noninteractive use, we should do this
		# with openssl
		subprocess.run(['/usr/share/easy-rsa/easyrsa', 'gen-req', cn, 'nopass'],
			cwd=self.confdir, input=b'\n')

		# Should have a private key and CSR now
		self.refresh()

		self.msgbox(f'Created secret key and certificate request for {cn}.')

	def onAuthorizePressed(self, *args):
		if self.cn == "":
			# No CN entered
			return

		# Get everything we need from site config
		siteDomain = self.siteConfig.get("domain")
		siteSsh = self.siteConfig.get("ssh", {})
		siteSshHostkey = siteSsh.get("hostkey")
		siteSshUser = siteSsh.get("user")
		siteSshHost = siteSsh.get("host")
		siteSshPort = siteSsh.get("port")
		siteScripts = self.siteConfig.get("scripts")

		if siteDomain == None or \
			siteSshHostkey == None or siteSshUser == None or \
			siteSshHost == None or siteSshPort == None or \
			siteScripts == None:
			# Shouldn't really happen for a well-formed site config
			# file, just return
			print(f'siteDomain: {siteDomain}')
			print(f'siteSshHostkey: {siteSshHostkey}')
			print(f'siteSshUser: {siteSshUser}')
			print(f'siteSshHost: {siteSshHost}')
			print(f'siteSshPort: {siteSshPort}')
			print(f'siteScripts: {siteScripts}')
			return

		os.makedirs(f'{self.confdir}/pki/issued', exist_ok=True)

		knownHostsFilePath = f'{self.confdir}/known_hosts'
		knownHostsFile = open(knownHostsFilePath, "w")
		knownHostsFile.write(siteSshHostkey)
		knownHostsFile.close()

		# Authorize the cert by signing it on the server using SSH
		csrFile = open(f'{self.confdir}/pki/reqs/{self.cn}.req', "rb")
		# Invoke SSH with a known_hosts file we created earlier and
		# strict host key checking (do not accept hosts not already
		# known).  We already know the server's identity, so do not
		# prompt the user.
		signReturnResult = subprocess.run(['ssh', '-o',
			f'UserKnownHostsFile={knownHostsFilePath}',
			'-o', 'GlobalKnownHostsFile=none',
			'-o', 'StrictHostKeyChecking=yes',
			'-p', siteSshPort,
			f'{siteSshUser}@{siteSshHost}',
			f'bash \'{siteScripts}/pqccomms-sign-return-client-cert\''],
			stdin=csrFile, capture_output=True,
			env={'SSH_ASKPASS': '/usr/lib/openssh/gnome-ssh-askpass', 'SSH_ASKPASS_REQUIRE': 'prefer'} | os.environ)
		csrFile.close()
		try:
			os.remove(knownHostsFilePath)
		except:
			pass

		if signReturnResult.returncode != 0 or len(signReturnResult.stdout) == 0:
			stderr = signReturnResult.stderr.decode("utf-8")
			if stderr != "":
				self.msgbox(stderr)
			else:
				self.msgbox('Authorization failed with unknown error.')
		else:
			certFile = open(f'{self.confdir}/pki/issued/{self.cn}.crt', "xb")
			certFile.write(signReturnResult.stdout)
			certFile.close()

			# Export the combined cert, private key, and CA
			subprocess.run(['/usr/share/easy-rsa/easyrsa', 'export-p12',
				self.cn, 'nopass'], cwd=self.confdir)

			self.msgbox(f'Authorized certificate for {self.cn}.')

		self.refresh()

	def onInstallAppsPressed(self, *args):
		# Install
		installResult = subprocess.run(['pqccomms-pwas', '--install'],
			capture_output=True)
		if installResult.returncode != 0:
			msg = join_lines([installResult.stdout.decode("utf-8"),
				installResult.stderr.decode("utf-8")])
			if msg == '':
				msg = 'Installation failed with unknown error.  Run \'pqccomms-pwas --install\' in terminal and check result.'
			self.msgbox(msg)
			return

		# Set Talk to start on login by default
		onLoginResult = subprocess.run(['pqccomms-pwas', '--on-login', 'talk'],
			capture_output=True)
		# We're not going to check if this succeeds/fails specifically,
		# just include whatever output it produced with the successful
		# install output.
		self.msgbox(join_lines([installResult.stdout.decode("utf-8"),
			  onLoginResult.stdout.decode("utf-8"),
			  onLoginResult.stderr.decode("utf-8")]))

	def onDeleteResetPressed(self, *args):
		if self.confirmbox(f'This will delete the secret key and certificate for {self.cn}.  To connect, you will need to generate a new secret key and authorize it.\n\nDelete anyway?'):
			# Delete secret key and certificate, if it exists
			shutil.rmtree(f'{self.confdir}/pki', ignore_errors=True)
			# Delete cn - the server won't allow creating another
			# certificate with the same CN anyway
			try:
				os.remove(f'{self.confdir}/cn')
			except:
				pass
			# Keep the site config though, another cert could be
			# created for the same site.  If the user wants a
			# different site, they can select another site once the
			# secret key is deleted.
			self.msgbox(f'Deleted secret key and certificate for {self.cn}.')
			self.refresh()

	def onExitPressed(self, *args):
		Gtk.main_quit()

	def onWindowDestroy(self, *args):
		Gtk.main_quit()

def main():
	client = Client()
	return Gtk.main()

if __name__ == '__main__':
	sys.exit(main())
