from __future__ import print_function

import sys
import os
import shutil
from os import path, environ
import io
import json
from argparse import ArgumentParser
import plistlib as plist
import re
from subprocess import check_call, check_output
import logging
import traceback
from xml.etree import ElementTree as ETree
from urllib.parse import urlparse
import urllib.request

logging.basicConfig(level=logging.INFO)

"""args is a plist-ish object. Find all the value strings in it, and replace all ocurrences of $(VAR) with VAR from the environment, and coalesce all strings in occurences of $(MACRO). We need this because Xcode plist preprocessing (which uses the C preprocessor in ancient compatibility mode in order to avoid interpreting // in URLs as comments) doesn't have a good way to coalesce those strings"""
def substitutePlist(args):
	try:
		# Gotta strip leading whitespace because plistlib can't even
		data = plist.loads(io.open(args.plist, "r", encoding="utf-8").read().strip().encode("utf-8"))
	except:
		print("error: %s is not a valid plist; this can happen if you used a // comment in a .h file that it's constructed from" % args.plist, file=sys.stderr)
		raise
	
	def processString(str):
		# Replace build variables in str
		def replaceVar(m):
			k = m.string[m.start(1):m.end(1)]
			return environ.get(k, k)

		# Replace $(VAR) with value of VAR from environment
		str = re.sub(r'\$\(([^:\)]+)\)', replaceVar, str)

		def replaceFragment(m):
			return m.string[m.start(1):m.end(1)].replace(r'\"', r'"')

		def replaceString(m):
			str = m.string[m.start(1):m.end(1)]
			# Replace "x\"y" with x"y
			str = re.sub(r'"((?:[^"]|(?:\\"))*)"(?:\s+|\Z)', replaceFragment, str)
			# Replace 'x\'y' with x'y
			str = re.sub(r"'((?:[^']|(?:\\'))*)'(?:\s+|\Z)", replaceFragment, str)
			return str

		# Replace $(MACRO:"x" "y" "z") with "xyz"
		str = re.sub(r'\$\((?:MACRO:((?:"([^"]|(?:\\"))*"\s+)*(?:"([^"]|(?:\\"))*")))\)', replaceString, str)

		# Replace $(MACRO:'x' 'y' 'z') with 'xyz'
		str = re.sub(r"\$\((?:MACRO:((?:'([^']|(?:\\'))*'\s+)*(?:'([^']|(?:\\'))*')))\)", replaceString, str)

		return str

	def processObj(o):
		# Recurse on dicts/lists
		if isinstance(o, dict):
			return dict([(k, processObj(v)) for (k, v) in o.items()])
		elif isinstance(o, list):
			return [processObj(i) for i in o]
		elif isinstance(o, str):
			return processString(o)
		else:
			return o

	with io.open(args.plist, "wb") as output:
		plist.dump(processObj(data), output)

"""Upload args.symbols to Microsoft App Center, extracting the relevant metadata from args.app"""
def uploadSymbols(args):
	if not "MSAC_API_KEY" in environ:
		print("warning: symbols not uploaded because API key was not provided. This is normal on issue branches and in development builds.", file=sys.stderr)
		return
	
	app = args.app
	dsym = args.symbols
	outputDir = path.dirname(dsym)

	dsymZip = path.join(outputDir, "%s.zip" % path.basename(dsym))
	uploadStartMeta = path.join(outputDir, "MSAC-symbol-upload-start.json")
	uploadEndMeta = path.join(outputDir, "MSAC-symbol-upload-end.json")
	
	appInfo = plist.load(io.open(path.join(app, "Contents", "Info.plist"), "rb"))
	version = appInfo["CFBundleVersion"]
	build = appInfo["com.fetchsoftworks.Fetch.Build"]

	check_call(["/usr/bin/zip", "-v", "-r", dsymZip, dsym])

	with io.open(uploadStartMeta, "w", encoding="utf-8") as output:
		json.dump(dict(symbol_type="Apple", file_name=path.basename(dsym), build=build, version=version), output)
	
	completed = False
	try:
		uploadStartResponse = json.loads(check_output([
			"/usr/bin/curl",
			"--silent",
			"--header", "X-API-Token: %s" % environ["MSAC_API_KEY"],
			"--header", "Content-Type: application/json",
			"--data-binary", "@%s" % uploadStartMeta,
			"%s/symbol_uploads" % environ["MSAC_API_ENDPOINT"]
		]))
		logging.info("MSAC start response: %s", uploadStartResponse)
	
		check_call([
			"/usr/bin/curl",
			"--silent",
			"--header", "X-API-Token: %s" % environ["MSAC_API_KEY"],
			"--header", "x-ms-blob-type: BlockBlob",
			"--upload-file", dsymZip,
			uploadStartResponse["upload_url"]
		])
		logging.info("MSAC upload transfer finished")

		completed = True

	except:
		logging.exception("MSAC upload setup failed")
		raise

	finally:
		try:
			with io.open(uploadEndMeta, "w") as output:
				json.dump(dict(status="committed" if completed else "aborted"), output)

			uploadEndResponse = json.loads(check_output([
				"/usr/bin/curl",
				"--silent",
				"--request", "PATCH",
				"--header", "X-API-Token: %s" % environ["MSAC_API_KEY"],
				"--header", "Content-Type: application/json",
				"--data-binary", "@%s" % uploadEndMeta,
				"%s/symbol_uploads/%s" % (environ["MSAC_API_ENDPOINT"], uploadStartResponse["symbol_upload_id"])
			]))
			logging.info("MSAC end response: %s", uploadEndResponse)
		except:
			logging.exception("MSAC upload completion failed")
			# If we're handling an error here, don't raise a new one
			if completed:
				raise
			else:
				pass

def runSourceChecks(args):
	runSourceCheck(
		"Fetch5/Application/ExceptionThrow.tops",
		"Replace bare C++ throw with Throw() for exception logging",
		logging.ERROR,
		exclude=["Fetch5/Application/ExceptionThrow.h"]
	)

def generateUpdateFiles(args):
	from markdown import markdown
	# Figure out where everything is going to go
	args.app = path.join(environ["FETCH_ROOT"], args.app)
	args.output = path.join(environ["FETCH_ROOT"], args.output)
	appInfo = plist.load(io.open(path.join(args.app, "Contents", "Info.plist"), "rb"))

	# Although the app makes an HTTP request to something like updates.fetchsoftworks.com/list/fetch-5ANNN.xml to check for updates, the
	# actual update metadata is stored at something like updates.fetchsoftworks.com/list/fetch-5_7-appcast.xml. The redirect from one to the
	# other is handled by a server-side script on updates.fetchsoftworks.com. The point of that is to allow the build system to generate
	# update metadata in such a way that we never have to manually edit it later (in part because metadata can be cryptographically signed
	# for better security). But once the metadata itself is immutable, the mapping from "what version of Fetch am I running" to "what version
	# of Fetch can I update to" has to be handled some other way — and right now, that other way is by means of configuration on
	# updates.fetchsoftworks.com
	checkURL = appInfo["SUFeedURL"]
	checkDir = appInfo["SUFeedDir"].rstrip('/')
	version = appInfo["com.fetchsoftworks.Fetch.ReleaseName"]
	m = re.match(r"([0-9.]+)(b|d|fc)?([0-9]*)", version)
	flavor = '-dev' if m.group(2) == "d" else '-beta' if m.group(2) == "b" in version else ""
	outputXML = f"""{ args.output }/{ checkDir }/fetch-{ m.group(1).replace(".", "_") }{ flavor }.xml"""
	outputPlist = f"""{ args.output }/{ checkDir }/fetch-{ m.group(1).replace(".", "_") }{ flavor }.plist"""
	outputPlistSignature = f"""{ outputPlist }.sig"""
	getURL = appInfo["SUDownloadURL"].rstrip('/')
	getDir = appInfo["SUDownloadDir"].rstrip('/')
	getName = f"Fetch_{ appInfo['com.fetchsoftworks.Fetch.ReleaseName'] }.zip"
	outputZip = f"{ args.output }/{ getDir }/{ getName }"

	# Zip the app
	os.makedirs(path.dirname(outputZip), exist_ok=True)
	check_call(["/usr/bin/ditto", "-c", "-k", "--keepParent", args.app, outputZip])
	
	# Generate Sparkle XML feed.
	# Have to clean out the cache first because generate_appcast caches contents of zip files based on their name, which creates problems when there are multiple builds with the same update filename, which happens for dev builds
	cacheDir = path.expanduser("~/Library/Caches/Sparkle_generate_appcast")
	if path.isdir(cacheDir):
		shutil.rmtree(cacheDir)
	sparkleOutput = check_output([path.join(environ["FETCH_ROOT"], "Pods/Sparkle/bin/generate_appcast"), path.dirname(outputZip)], encoding="utf-8")
	print(sparkleOutput)
	m = re.match(r"(?ms).*Written (.*) based on.*", sparkleOutput)

	# Rewrite URL in feed
	xmlPath = m.group(1)
	xml = ETree.parse(xmlPath)
	rss = xml.getroot()
	channel = rss.find('channel')
	for item in channel.findall('item'):
		if args.releaseNotes:
			description = item.find('description')
			if not description:
				description = ETree.SubElement(item, 'description')
			description.text = markdown(open(args.releaseNotes).read())

		enclosure = item.find('enclosure')
		enclosure.set("url", f"{ getURL }/{ getName }")
		enclosure.set(ETree.QName("{http://www.andymatuschak.org/xml-namespaces/sparkle}version"), appInfo["CFBundleVersion"])
		enclosure.set(ETree.QName("{http://www.andymatuschak.org/xml-namespaces/sparkle}shortVersionString"), appInfo["com.fetchsoftworks.Fetch.ReleaseName"])
	
	xml.write(xmlPath)

	# Put the XML feed where we want it to be
	os.makedirs(path.dirname(outputXML), exist_ok=True)
	os.rename(xmlPath, outputXML)
	
	# Legacy update stuff — can be removed after 5.8 ships
	# Legacy updates need three files: an update metadata file (plist), a metadata signature, and a downloadable file (zip).
	if version == "5.8":
		# Update info plist comes from template
		updateInfo = plist.load(io.open(path.join(environ["FETCH_ROOT"], "Fetch5", "Application", "LegacyUpdate.plist"), "rb"))
		updateInfo["Build"] = appInfo["com.fetchsoftworks.Fetch.Build"]
		with io.open(outputPlist, "wb") as output:
			plist.dump(updateInfo, output)
			
		# Sign the legacy update info
		signature = check_output(["/usr/bin/openssl", "sha1", "-sign", environ["SOFTWARE_UPDATE_RSA_KEY"], "-binary", outputPlist])
		with io.open(outputPlistSignature, "wb") as output:
			output.write(signature)

		# Downloadable zip is the same as what we're using for Sparkle
	else:
		logging.warning("Legacy update files not generated because Fetch version is != 5.8")

def extractNotarizationUploadID(args):
	# Extract upload ID from output of altool --notarize app. The easy case is when the upload ID is returned directly; the other case is when the upload ID is buried inside a "repeat upload" error message
	try:
		responseStr = sys.stdin.read()
		response = plist.loads(responseStr.encode("utf-8"))
		
		uploadID = response.get("notarization-upload", {}).get("RequestUUID", "")
		if not uploadID and len(response.get("product-errors", [])) == 1:
			message = response["product-errors"][0]["message"]
			m = re.match(r"ERROR ITMS-90732:.*(\w\w\w\w\w\w\w\w-\w\w\w\w-\w\w\w\w-\w\w\w\w-\w\w\w\w\w\w\w\w\w\w\w\w).*", message)
			if m:
				uploadID = m.group(1)

		if uploadID:
			print(uploadID)
		else:
			raise(RuntimeError("Couldn't find Application Loader upload ID"))
	except:
		print(f"altool response:\n{responseStr}", file=sys.stderr)
		raise

def checkNotarization(args):
	# Extract notarization info from output of altool --notarization-info. Return 0 when notarization is pending, >0 when it's done (error or not)
	responseStr = sys.stdin.read()
	response = plist.loads(responseStr.encode("utf-8"))
	
	if response.get("notarization-info", {}).get("Status", None) == "in progress":
		logging.info("Notarization (still) in progress…")
		return
	else:
		logURL = response.get("notarization-info", {}).get("LogFileURL", None)
		result = str(urllib.request.urlopen(logURL).read(), "utf-8") if logURL else None

		if response.get("notarization-info", {}).get("Status", None) == "success":
			logging.warning("Notarization succesful; outcome:")
			logging.warning("%s", result)
			exit(1)
		else:
			logging.error("Notarization failed; outcome:")
			logging.error("%s", result)
			exit(2)

def hardenEmbeddedBundles(args):
	for bundlePath in args.paths:
		check_call(["/usr/bin/codesign", "--verbose", "--force", "--deep", "--options", "runtime", "--sign", environ["EXPANDED_CODE_SIGN_IDENTITY_NAME"], bundlePath])

def generateReleaseInfo(args):
	# GitLab release info is various GItLab metadata + Markdown release notes, all in JSON
	releaseNotes = open("Fetch5/Application/Release Notes.md").read()
	releaseName = re.sub(r".*/(.*)", r"\1", environ["CI_COMMIT_REF_NAME"]).replace('-', ' ')
	gitlabInfo = dict(
		name=releaseName,
		tag_name=environ["CI_COMMIT_REF_NAME"],
		description=releaseNotes,
		assets=dict(
			links=[
				dict(
					name="WebStore app",
					url=f'{ environ["CI_PROJECT_URL"] }/-/jobs/{ environ["CI_JOB_ID"] }/artifacts/download'
				)
			]
		)
	)

	os.makedirs(path.dirname(args.gitlab), exist_ok=True)
	json.dump(gitlabInfo, open(args.gitlab, "w"))

def main():
	parser = ArgumentParser(prog='BuildTools.py')
	subparsers = parser.add_subparsers()

	sub = subparsers.add_parser('substitutePlist', help='substitute build variables and fix preprocessor string interpolation in plist')
	sub.add_argument('plist', help='path to plist')
	sub.set_defaults(func=substitutePlist)

	sub = subparsers.add_parser('uploadSymbols', help='upload symbols to Microsoft App Center')
	sub.add_argument('app', help='path to app')
	sub.add_argument('symbols', help='path to app dSYM')
	sub.set_defaults(func=uploadSymbols)

	sub = subparsers.add_parser('runSourceChecks', help='run source checks')
	sub.set_defaults(func=runSourceChecks)
	
	sub = subparsers.add_parser('generateUpdateFiles', help='generate update (Sparkle and legacy) files')
	sub.add_argument('app', help='path to .app')
	sub.add_argument('releaseNotes', help='path to release notes (Markdown)')
	sub.add_argument('output', help='output path')
	sub.set_defaults(func=generateUpdateFiles)

	sub = subparsers.add_parser('extractNotarizationUploadID', help='extract upload ID from altool --notarize-app output')
	sub.set_defaults(func=extractNotarizationUploadID)

	sub = subparsers.add_parser('checkNotarization', help='check altool --notarization-info to determine whether notarization completed')
	sub.set_defaults(func=checkNotarization)

	sub = subparsers.add_parser('hardenEmbeddedBundles', help='Turn on hardened runtime for embedded 3rd party bundles')
	sub.add_argument('paths', nargs='+', help='bundle paths')
	sub.set_defaults(func=hardenEmbeddedBundles)

	sub = subparsers.add_parser('generateReleaseInfo', help='Generate release notes for sparkle (html) and release metadata for GitLab (json)')
	sub.add_argument('gitlab', help='GitLab JSON release info file')
	sub.set_defaults(func=generateReleaseInfo)

	args = parser.parse_args()
	logging.debug("BuildTools invoked with: %s", args)
	args.func(args)

def runSourceCheck(check, message, level, exclude):
	os.chdir(environ["FETCH_ROOT"])
	checkRoot = "Fetch5"

	sourceExtensions = [".c", ".cp", ".cpp", ".h", ".hpp", ".m", ".mm", ]
	sources = [
		source for source in [
			path.normpath(path.join(dirpath, filename))
				for dirpath, _, filenames in os.walk(checkRoot)
				for filename in filenames
				if path.splitext(filename)[1] in sourceExtensions
		] if not source in exclude
	]
	
	output = check_output(["/usr/bin/tops", "-dont", "-scriptfile", check] + sources)
	output = [parseTopsLine(line) for line in str(output, "utf-8").strip().split("\n") if line]
	tag = "error" if level == logging.ERROR else "Warning"
	output = [f"{path.abspath(file)}:{lineno}: {tag}: {message}\n{path.abspath(file)}:{lineno}: warning: {hint}\n" for (file, lineno, hint) in output]
	if output:
		output = "".join(output)
		print(output, file=sys.stderr)
		if level == logging.ERROR:
			sys.exit(1)
			raise RuntimeError(output)
		else:
			return output

def parseTopsLine(line):
	file, lineno, message = line.split(":", 2)
	message = message.strip()

	return file, lineno, message

if __name__ == '__main__':
	try:
		main()
	except Exception as e:
		lines = traceback.format_exc().split("\n")
		exc = [f"error: {line}" for line in lines if line]
		print("\n".join(exc))
		sys.exit(1)
