#!/usr/bin/python3

# RetroShare JSON API generator
#
# Copyright (C) 2019  selankon <selankon@selankon.xyz>
# Copyright (C) 2021-2022  Gioacchino Mazzurco <gio@retroshare.cc>
# Copyright (C) 2021-2022  Asociación Civil Altermundi <info@altermundi.net>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation, version 3.
#
# This program is distributed 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 along
# with this program. If not, see <https://www.gnu.org/licenses/>
#
# SPDX-FileCopyrightText: Retroshare Team <contact@retroshare.cc>
# SPDX-License-Identifier: AGPL-3.0-only


# Original idea and implementation by G10h4ck (jsonapi-generator.cpp)
# Initial python reimplementation by Sehraf
#
# This python 3 script has superseded the original C++/Qt implementation this
# and is now used at build time in without depending on Qt.

import os
import sys

import xml.etree.ElementTree as ET
from string import Template


class MethodParam:
	_type = ''
	_name = ''
	_defval = ''
	_in = False
	_out = False
	_isMultiCallback = False
	_isSingleCallback = False


class TemplateOwn(Template):
	delimiter = '$%'
	pattern = '''
	\$%(?:
		(?P<escaped>\$\%) |   					# Escape sequence of two delimiters
		(?P<named>[_a-z][_a-z0-9]*)%\$      |   	# delimiter and a Python identifier
		{(?P<braced>[_a-z][_a-z0-9]*)}   |   	# delimiter and a braced identifier
		(?P<invalid>)              				# Other ill-formed delimiter exprs
	)
	'''


def getText(e):
	return "".join(e.itertext())


def processFile(file):
	try:
		dom1 = ET.parse(file).getroot()
	except FileNotFoundError:
		print('Can\'t open:', file)

	headerFileInfo = dom1[0].findall('location')[0].attrib['file']
	headerRelPath = os.path.dirname(headerFileInfo).split('/')[-1] + '/' + os.path.basename(headerFileInfo)

	for sectDef in dom1.findall('.//memberdef'):
		if sectDef.attrib['kind'] != 'variable' or sectDef.find('.//jsonapi') == None:
			continue

		instanceName = sectDef.find('name').text
		typeName = sectDef.find('type/ref').text

		typeFilePath = sectDef.find('type/ref').attrib['refid']

		try:
			dom2 = ET.parse(doxPrefix + typeFilePath + '.xml').getroot()
		except FileNotFoundError:
			print('Can\'t open:', doxPrefix + typeFilePath + '.xml')

		for member in dom2.findall('.//member'):
			refid = member.attrib['refid']
			methodName = member.find('name').text

			requiresAuth = True

			# As of 2022/03/24 doxygen behaves differently on MacOS and on Linux
			# on Linux we get
			# refid classRsBroadcastDiscovery_1a62f461807c36d60db35794cdb70bf032
			# file name classRsBroadcastDiscovery.xml
			# on MacOS we get
			# refid class_rs_broadcast_discovery_1a62f461807c36d60db35794cdb70bf032
			# file name class_rs_broadcast_discovery.xml
			# which have more the one _ therfore we need to use reverse single
			# split to build the file name from refid in a way that works on
			# both systems
			# On Windows haven't got reports about this so it probably behave
			# like on Linux, but now should work anyway.
			defFileName = refid.rsplit('_',1)[0] + '.xml'

			print( 'Looking for', typeName, methodName, 'into', defFileName,
			       "calculated from refid:", refid )

			try:
				defDoc = ET.parse(doxPrefix + defFileName).getroot()
			except FileNotFoundError:
				print( 'Can\'t open:', doxPrefix + defFileName,
				       "not found between:" )
				for mFileName in os.listdir(doxPrefix):
					print(mFileName)
				raise

			memberdef = None
			for tmpMBD in defDoc.findall('.//memberdef'):
				tmpId = tmpMBD.attrib['id']
				tmpKind = tmpMBD.attrib['kind']
				tmpJsonApiTagList = tmpMBD.findall('.//jsonapi')

				if len(tmpJsonApiTagList) != 0 and tmpId == refid and tmpKind == 'function':
					tmpJsonApiTag = tmpJsonApiTagList[0]

					tmpAccessValue = None
					if 'access' in tmpJsonApiTag.attrib:
						tmpAccessValue = tmpJsonApiTag.attrib['access']

					requiresAuth = 'unauthenticated' != tmpAccessValue;

					if 'manualwrapper' != tmpAccessValue:
						memberdef = tmpMBD

					break

			if memberdef == None:
				continue

			apiPath = '/' + instanceName + '/' + methodName

			retvalType = getText(memberdef.find('type'))
			# Apparently some xml declarations include new lines ('\n') and/or multiple spaces
			# Strip them using python magic
			retvalType = ' '.join(retvalType.split())

			paramsMap = {}
			orderedParamNames = []

			hasInput = False
			hasOutput = False
			hasSingleCallback = False
			hasMultiCallback = False
			callbackName = ''
			callbackParams = ''

			for tmpPE in memberdef.findall('param'):
				mp = MethodParam()

				pName = getText(tmpPE.find('declname'))
				tmpDefval = tmpPE.find('defval')
				mp._defval = getText(tmpDefval) if tmpDefval != None else ''
				pType = getText(tmpPE.find('type'))

				if pType.startswith('const '): pType = pType[6:]
				if pType.startswith('std::function'):
					if pType.endswith('&'): pType = pType[:-1]
					if pName.startswith('multiCallback'):
						mp._isMultiCallback = True
						hasMultiCallback = True
					elif pName.startswith('callback'):
						mp._isSingleCallback = True
						hasSingleCallback = True
					callbackName = pName
					callbackParams = pType
				else:
					pType = pType.replace('&', '').replace(' ', '')
				
				# Apparently some xml declarations include new lines ('\n') and/or multiple spaces
				# Strip them using python magic
				pType = ' '.join(pType.split())
				mp._defval = ' '.join(mp._defval.split())

				mp._type = pType
				mp._name = pName

				paramsMap[pName] = mp
				orderedParamNames.append(pName)

			for tmpPN in memberdef.findall('.//parametername'):
				tmpParam = paramsMap[tmpPN.text]
				tmpD = tmpPN.attrib['direction'] if 'direction' in tmpPN.attrib else ''

				if 'in' in tmpD:
					tmpParam._in = True
					hasInput = True
				if 'out' in tmpD:
					tmpParam._out = True
					hasOutput = True

			# Params sanity check
			for pmKey in paramsMap:
				pm = paramsMap[pmKey]
				if not (pm._isMultiCallback or pm._isSingleCallback or pm._in or pm._out):
					print( 'ERROR', 'Parameter:', pm._name, 'of:', apiPath,
							  'declared in:', headerRelPath,
							  'miss doxygen parameter direction attribute!',
							  defFileName )
					sys.exit(-1)

			functionCall = '\t\t'
			if retvalType != 'void':
				functionCall += retvalType + ' retval = '
				hasOutput = True
			functionCall += instanceName + '->' + methodName + '('
			functionCall += ', '.join(orderedParamNames) + ');\n'

			print(instanceName, apiPath, retvalType, typeName, methodName)
			for pn in orderedParamNames:
				mp = paramsMap[pn]
				print('\t', mp._type, mp._name, mp._in, mp._out)

			inputParamsDeserialization = ''
			if hasInput:
				inputParamsDeserialization += '\t\t{\n' 
				inputParamsDeserialization += '\t\t\tRsGenericSerializer::SerializeContext& ctx(cReq);\n' 
				inputParamsDeserialization += '\t\t\tRsGenericSerializer::SerializeJob j(RsGenericSerializer::FROM_JSON);\n';

			outputParamsSerialization = ''
			if hasOutput:
				outputParamsSerialization += '\t\t{\n'
				outputParamsSerialization += '\t\t\tRsGenericSerializer::SerializeContext& ctx(cAns);\n'
				outputParamsSerialization += '\t\t\tRsGenericSerializer::SerializeJob j(RsGenericSerializer::TO_JSON);\n';

			paramsDeclaration = ''
			for pn in orderedParamNames:
				mp = paramsMap[pn]
				paramsDeclaration += '\t\t' + mp._type + ' ' + mp._name
				if mp._defval != '':
					paramsDeclaration += ' = ' + mp._defval
				paramsDeclaration += ';\n'
				if mp._in:
					inputParamsDeserialization += '\t\t\tRS_SERIAL_PROCESS('
					inputParamsDeserialization += mp._name + ');\n'
				if mp._out:
					outputParamsSerialization += '\t\t\tRS_SERIAL_PROCESS('
					outputParamsSerialization += mp._name + ');\n'

			if hasInput: 
				inputParamsDeserialization += '\t\t}\n'
			if retvalType != 'void': 
				outputParamsSerialization += '\t\t\tRS_SERIAL_PROCESS(retval);\n'
			if hasOutput:
				outputParamsSerialization += '\t\t}\n'

			captureVars = ''

			sessionEarlyClose = ''
			if hasSingleCallback:
				sessionEarlyClose = 'session->close();'

			sessionDelayedClose = ''
			if hasMultiCallback:
				sessionDelayedClose = """
		RsThread::async( [=]()
		{
			std::this_thread::sleep_for(
				std::chrono::seconds(maxWait+120) );
			auto lService = weakService.lock();
			if(!lService || lService->is_down()) return;
			lService->schedule( [=]()
			{
				auto session = weakSession.lock();
				if(session && session->is_open())
					session->close();
			} );
		} );
"""
				captureVars = 'this'

			callbackParamsSerialization = ''

			if hasSingleCallback or hasMultiCallback or (callbackParams.find('(') + 2 < callbackParams.find(')')):
				cbs = ''

				callbackParams = callbackParams.split('(')[1]
				callbackParams = callbackParams.split(')')[0]

				cbs += '\t\t\tRsGenericSerializer::SerializeContext ctx;\n'

				for cbPar in callbackParams.split(','):
					isConst = cbPar.startswith('const ')
					pSep = ' '
					isRef = '&' in cbPar
					if isRef: pSep = '&'
					sepIndex = cbPar.rfind(pSep) + 1
					cpt = cbPar[0:sepIndex][6:]
					cpn = cbPar[sepIndex:]

					cbs += '\t\t\tRsTypeSerializer::serial_process('
					cbs += 'RsGenericSerializer::TO_JSON, ctx, '
					if isConst:
						cbs += 'const_cast<'
						cbs += cpt
						cbs += '>('
					cbs += cpn
					if isConst: cbs += ')'
					cbs += ', "'
					cbs += cpn
					cbs += '" );\n'

				callbackParamsSerialization += cbs

			substitutionsMap = dict()
			substitutionsMap['paramsDeclaration'] = paramsDeclaration
			substitutionsMap['inputParamsDeserialization'] = inputParamsDeserialization
			substitutionsMap['outputParamsSerialization'] = outputParamsSerialization
			substitutionsMap['instanceName'] = instanceName
			substitutionsMap['functionCall'] = functionCall
			substitutionsMap['apiPath'] = apiPath
			substitutionsMap['sessionEarlyClose'] = sessionEarlyClose
			substitutionsMap['sessionDelayedClose'] = sessionDelayedClose
			substitutionsMap['captureVars'] = captureVars
			substitutionsMap['callbackName'] = callbackName
			substitutionsMap['callbackParams'] = callbackParams
			substitutionsMap['callbackParamsSerialization'] = callbackParamsSerialization
			substitutionsMap['requiresAuth'] = 'true' if requiresAuth else 'false'

			# print(substitutionsMap)

			templFilePath = sourcePath
			if hasMultiCallback  or hasSingleCallback:
				templFilePath += '/async-method-wrapper-template.cpp.tmpl'
			else:
				templFilePath += '/method-wrapper-template.cpp.tmpl'

			templFile = open(templFilePath, 'r')
			wrapperDef = TemplateOwn(templFile.read())

			tmp = wrapperDef.substitute(substitutionsMap)
			wrappersDefFile.write(tmp)

			cppApiIncludesSet.add('#include "' + headerRelPath + '"\n')


if len(sys.argv) != 3:
	print('Usage:', sys.argv[0], 'SOURCE_PATH OUTPUT_PATH Got:', sys.argv[:])
	sys.exit(-1)

sourcePath = str(sys.argv[1])
outputPath = str(sys.argv[2])
doxPrefix = outputPath + '/xml/'

try:
	wrappersDefFile = open(outputPath + '/jsonapi-wrappers.inl', 'w')
except FileNotFoundError:
	print('Can\'t open:', outputPath + '/jsonapi-wrappers.inl')

try:
	cppApiIncludesFile = open(outputPath + '/jsonapi-includes.inl', 'w');
except FileNotFoundError:
	print('Can\'t open:', outputPath + '/jsonapi-includes.inl')

cppApiIncludesSet = set()

filesIterator = None
try:
	filesIterator = os.listdir(doxPrefix)
except FileNotFoundError:
	print("Doxygen xml output dir not found: ", doxPrefix)
	sys.exit(-1)

for file in filesIterator:
	if file.endswith("8h.xml"):
		processFile(os.path.join(doxPrefix, file))


for incl in cppApiIncludesSet:
	cppApiIncludesFile.write(incl)
