"""
A custom renderer for registering VO resources with the DOI system.
"""

import base64
import datetime
import hashlib
import os

import requests
from lxml import etree
from twisted.internet import threads
from twisted.python import threadable

from twisted.web import template
from twisted.web.template import tags as T

from gavo import api
from gavo import formal
from gavo import utils
from gavo import web
from gavo.base import osinter
from gavo.helpers import testtricks
from gavo.grammars import kvgrammar
from gavo.protocols import oaiclient
from gavo.svcs import weberrors
from gavo.web import formrender

# we need the following armor for validation since the naked OAI record we're
# slinging around is invalid per OAI
OAI_ARMOR = """
<oai:OAI-PMH
	xmlns:oai="http://www.openarchives.org/OAI/2.0/"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
	<oai:responseDate>2016-01-21T15:52:38Z</oai:responseDate>
	<oai:request metadataPrefix="ivo_vor" verb="GetRecord"/>
	<oai:GetRecord>
	%s
	</oai:GetRecord>
</oai:OAI-PMH>
"""


def _makeConfirmationMail(ivoid, rec, destURL):
	"""returns a mail (including header) to ask for confirmation of DOI
	registration of rec via destURL.
	"""
	return (
		"""From: GAVO DOI admin <%(adminAddr)s>
To: %(destAddr)s
Subject: Your DOI Registration

Hello,

Someone (hopefully you) asked for a DOI to be created for the
Virtual Observatory resource %(ivoid)s with the title
%(title)s.

You are listed as the contact person for that resource in the
VO registry.  If you agree that a DOI should be created for your
resource, go the the following URL:

%(destURL)s

If you did not ask for a DOI, apologies.  DO NOT retrieve the above link
unless you know what you are doing.  The creation of a DOI is IRREVERSIBLE
(at least a tombstone will always remain).

As someone maintaining a VO resource, you may still be interested what this
is about.  If you are, see http://dc.g-vo.org/voidoi/q/ui/info.

Thanks.
""")%{
		"adminAddr": api.getConfig("maintainerAddress"),
		"destAddr": rec["contact.email"],
		"title": rec["title"],
		"ivoid": ivoid,
		"destURL": destURL,}


def makeConfirmationKey():
	"""returns a random ASCII-string.
	"""
	# md5 use doesn't matter since we're hashing random stuff only anyway
	with open("/dev/urandom", "rb") as f:
		return hashlib.md5(f.read(8)).hexdigest()
	

def getRegistryRecord(ivoid):
	"""returns the XML form of the registry record.

	This will remove any OAI wrapper; so, the root element here should
	have ri:Record as its tag.
	"""
	# test instrumentation
	if ivoid.startswith("ivo://org.gavo.dc/test/"):
		reg_test_data, _ = api.loadPythonModule("reg_test_data",
			relativeTo=__file__)
		return reg_test_data.RECORDS[ivoid]

	try:
		return oaiclient.getRecord(api.getMetaText(RD, "oai_endpoint"), ivoid)
	except oaiclient.FailedQuery as ex:
		if ex.code=="idDoesNotExist":
			raise api.ValidationError("Could not find the record belonging"
				" the ivoid %s.  See the Service Info for ways to try and fix"
				" that."%ivoid, "ivoid")
		raise


def storeRequest(doiTable, ivoid, fullRecord):
	"""stores the request to register ivoid in the recs table.
	"""
	try:
	 row = doiTable.getRow(ivoid)
	 row["full_record"] = fullRecord
	 doiTable.addRow(row)
	except KeyError:
		row = {
			"ivoid": ivoid,
			"date_reg": datetime.datetime.utcnow(),
			"doi": None,
			"full_record": fullRecord,
			"last_confirmation_code": makeConfirmationKey(),}
		doiTable.addRow(row)
	return doiTable.getRow(ivoid)


def _makeConfirmationURL(row):
	"""returns the URL under which to confirm the DOI generation.
	"""
	return RD.getById("ui").getURL("custom"
		)+"/confirm/"+row["last_confirmation_code"]


def checkTransformation(row):
	"""validates the VOResource record and its translatability to
	datacite metadata.

	Expectable Errors will come out as ValidationErrors for the ivoid field.

	If successful, a list of warnings and a provisional etree of the parsed
	record will be returned.  This etree contains the wrong DOI.  Use
	DATACITE_INTERFACE.translate directly to get uploadable material.
	"""
	warnings = []

	if row["doi"]:
		raise api.ValidationError("This ivoid has already been assigned"
			" the DOI %s by this service."%row["doi"], "ivoid")
	
	try:
		errorMsgs = testtricks.getXSDErrors(OAI_ARMOR%row["full_record"])
	except Exception as msg:
		raise api.ValidationError("Serious error while trying to validate"
			" the resource record; it is probably not well-formed.  The error"
			" message was: %s"%utils.safe_str(msg), "ivoid")
	if errorMsgs:
		raise api.ValidationError("The resource record is invalid -- you need to"
			" fix it first (see below for a hint on where the problem is).",
			"ivoid", hint=errorMsgs)

	parsedRecord = etree.fromstring(row["full_record"].encode("utf-8"))
	_, dc_tree = DATACITE_INTERFACE.translate(parsedRecord, lambda id: "000/000")
	# as long as the DC schema is a 7-file mess, I postpone this
	# errorMsgs = testtricks.getXSDErrors(etree.tostring(dc_tree, "utf-8"))
	if False: #errorMsgs:
		raise api.ValidationError("The datalink document resulting from"
			" your Resource record is invalid.  This is probably our fault,"
			" please contact us.", "ivoid", hint=errorMsgs)

	return warnings, dc_tree


def prepareMailing(row):
	"""returns a a mail text for notification and the contact email address.
	"""
	rec = oaiclient.parseRecord(OAI_ARMOR%row["full_record"])
	contactMail = rec.get("contact.email")
	if not contactMail:
		raise api.ValidationError("The record with this ivoid does not"
			" give a contact/email item.  Add one and try again.", "ivoid")

	return _makeConfirmationMail(row["ivoid"], rec,
		_makeConfirmationURL(row)), contactMail


def makeLandingPageURL(doi):
	"""returns the URI of a landing page for the record with doi.
	"""
	return RD.getById("lp").getURL("custom")+"/"+doi


def makeABackup(doiTable):
	"""writes a dump of doiTable to a dumps/current.dump.
	"""
	dp = doiTable.tableDef.rd.getAbsPath("dumps")
	utils.ensureDir(dp)
	with utils.safeReplaced(os.path.join(dp, "current.dump")) as f:
		api.createDump([doiTable], f)


def makeDOI(doiTable, rec):
	"""creates a DOI for the voidoi.recs record rec.

	As a side effect, the DOI will be written to the database, and
	the metadata will be uploaded  to datacite.
	"""
	newDOI = DATACITE_INTERFACE.get_DOI(rec["ivoid"])
	for retry in range(5):
		try:
			with doiTable.connection.savepoint():
				rowcount = doiTable.connection.execute(
					doiTable.tableDef.expand("UPDATE \\qName SET doi=%(doi)s"
					" WHERE last_confirmation_code=%(cc)s"),
					{"doi": newDOI, "cc": rec["last_confirmation_code"]})
				if rowcount!=1:
					# confirmation request vanished in between
					raise api.UnknownURI("Vanished")
				break
		except api.DBError:
			newDOI = newDOI+"_"
	else:
		# persistent collisions.  There must be something broken.
		raise api.ReportableError("Cannot generate a DOI because"
			" of persistent failures.  This is serious, please alert the"
			" operators.")

	recId, dcTree = DATACITE_INTERFACE.translate(
		etree.fromstring(rec["full_record"]), lambda id: newDOI)
	if DATACITE_INTERFACE.datacite_endpoint:
		try:
			DATACITE_INTERFACE.upload(dcTree, makeLandingPageURL(newDOI))
		except requests.exceptions.RequestException as ex:
			hint = None
			if getattr(ex, "response", None):
				hint = ("Here's the payload of DataCite's error response: "
						+utils.safe_str(ex.response.text))
				api.ui.notifyWarning("Datacite API error: %s"%
					utils.safe_str(ex.response.text))
			else:
				api.ui.notifyWarning(
					"General exception while using the datacite API: %s"%ex)

			with open(os.path.join(api.getConfig("stateDir"),
					"last-mdc-failure.xml"), "w", encoding="utf-8") as f:
				f.write(str(dcTree))

			ex = api.ui.logOldExc(
				api.ReportableError("We are sorry, but the DOI registration"
					" has not worked out ('%s')."
					" from DataCite's API."%utils.safe_str(ex),
					hint=hint))
			raise ex

	rec["doi"] = newDOI
	rec["last_confirmation_code"] = None
	doiTable.addRow(rec)
	doiTable.connection.commit()

	makeABackup(doiTable)
	return rec


def runConfirmation(confirmationCode):
	"""updates the record having confirmationCode to use its DOI.

	If no matching confirmation code can be found, this raises an UnknownURI
	exception with an appropriate explanation.

	The function will return the db record of the updated resource record
	if succesful.
	"""
	try:
		with api.getWritableAdminConn() as conn:
			doiTable = api.TableForDef(RD.getById("dois"), connection=conn)
			rows = list(doiTable.iterQuery(doiTable.tableDef,
				"last_confirmation_code=%(lcc)s",
				{"lcc": confirmationCode}))
			if not rows:
				raise api.UnknownURI("Rows")

			rec = makeDOI(doiTable, rows[0])
			return rec
	except api.UnknownURI:
		# provide some explanation to display.
		raise api.UnknownURI("Invalid confirmation code.",
			hint="This could be because you have waited too long (we generally"
				" cancel confirmation keys around midnight UTC) or because"
				" someone has requested a different confirmation key in the"
				" meantime.  Please try again.")


class WaitingPage(web.CustomTemplateMixin,
		web.ServiceBasedPage):
	name = "custom"
	customTemplate = "res/wait_for_mail.html"

	def __init__(self, destMail, ctx, service):
		web.ServiceBasedPage.__init__(self, ctx, service)
		self.destMail = destMail
	
	def data_destMail(self, ctx, data):
		return self.destMail


class ConfirmationPage(
		formal.ResourceWithForm,
		web.CustomTemplateMixin,
		web.ServiceBasedPage):
	name = "custom"
	customTemplate = "res/confirmation.html"

	actionInfo = ""

	def __init__(self, segments, request, service):
		web.ServiceBasedPage.__init__(self, request, service)
		try:
			self.confirmationCode = segments[0]
			with api.getTableConn() as conn:
				self.resRec = list(
					conn.queryToDicts("select * from voidoi.dois where"
						" last_confirmation_code=%(cc)s",
						{"cc": self.confirmationCode}
					))[0]
		except IndexError:
			raise api.UnknownURI("No such confirmation code known here."
				"  Perhaps it is expired?  Please try again.")

	def form_areyousure(self, request, data={}):
		form = formal.Form()
		form.addField('decision', formal.String(), widgetFactory=formal.Hidden)
		form.data = {
			'decision': 'made',}

		form.addAction(self.cancelConfirmation, name="cancel",
			label="Cancel DOI creation")
		form.addAction(self.confirmConfirmation,
			name="confirm",
			label="Confirm DOI creation")
		return form

	@template.renderer
	def ifchoice(self, request, tag):
		if "decision" in request.strargs:
			return ""
		return tag

	@template.renderer
	def ifregistered(self, request, tag):
		if self.resRec.get("doi") is not None:
			return tag
		return ""

	def data_registredRecord(self, request, tag):
		"""puts a dictionary with the registration record into the context.

		This will be None unless confirmConfirmation has run.
		"""
		return getattr(self, "resRec", None)

	@template.renderer
	def actioninfo(self, request, tag):
		if self.actionInfo:
			return tag[T.xml(self.actionInfo)]
		return ""
	
	def cancelConfirmation(self, request, form, data):
		with api.getWritableAdminConn() as conn:
			conn.execute(
				r"DELETE FROM voidoi.dois WHERE last_confirmation_code=%(lcc)s",
				{"lcc": self.confirmationCode})

		self.actionInfo = T.p["DOI creation for has been canceled."]
		return self
	
	def confirmConfirmation(self, request, form, data):
		return threads.deferToThread(
			runConfirmation, self.confirmationCode
			).addCallback(self._renderConfirmation, request)
		
	def _renderConfirmation(self, resRec, request):
		# TODO: move text into the template
		self.resRec = resRec
		return web.ServiceBasedPage.render(self, request)

	def crash(self, failure, request):
		return weberrors.renderDCErrorPage(failure, request)


class MainPage(
		formal.ResourceWithForm,
		web.CustomTemplateMixin,
		web.ServiceBasedPage):

	name = "custom"
	customTemplate = "res/registration.html"

	_excHint = None

	@classmethod
	def isBrowseable(self, service):
		return True

	def form_genForm(self, request, data={}):
		self.ivoform = formal.Form()
		self.ivoform.addField(
			"ivoid", formal.String(required=True), label="IVOID",
			description="An IVOID for a registred VO resource"),
		self.ivoform.addAction(self.submitAction, label="Next")
		return self.ivoform

	@template.renderer
	def excHint(self, request, tag):
		if self._excHint:
			return [T.h2["Hints on fixing the problem"],
				tag[self._excHint],
				T.p["If this does not help, please consult the  ",
					T.a(href="mailto:"+api.getConfig("maintainerAddress"))[
					"site operators"],"."]]
		return ""

	def submitAction(self, request, form, data):
		return threads.deferToThread(
			self.runRegistrationFor, data["ivoid"]
			).addCallback(self._renderResponse, request
			).addErrback(self._handleInputErrors)

	def runRegistrationFor(self, ivoid):
		"""runs the registration for ivoid up to sending the confirmation mail.

		This returns the address the e-mail has been sent to.
		"""
		rawRecord = getRegistryRecord(ivoid)
		with api.getWritableAdminConn() as conn:
			doiTable = api.TableForDef(RD.getById("dois"), connection=conn)
			row = storeRequest(doiTable, ivoid, rawRecord)

		# TODO: Render Warnings
		self.warnings = checkTransformation(row)

		mailToSend, contactAddress = prepareMailing(row)
		osinter.sendMail(mailToSend)
		return contactAddress

	def _renderResponse(self, result, request):
		WaitingPage(result, request, self.service).render(request)
		return ""

	def _handleInputErrors(self, failure):
		"""goes as an errback to form handling code to allow correction form
		rendering at later stages than validation.
		"""
		if hasattr(failure.value, "hint"):
			self._excHint = failure.value.hint

		if isinstance(failure.value, api.ValidationError
				) and isinstance(failure.value.colName, str):
			try:
				# Find out the formal name of the failing field...
				failedField = failure.value.colName
				# ...and make sure it exists
				self.ivoform.items.getItemByName(failedField)
				self.ivoform.errors.add(formal.FieldValidationError(
					str(failure.getErrorMessage()), failedField))
			except KeyError: # Failing field cannot be determined
				self.ivoform.errors.add(formal.FormError("Problem with input"
					" in the internal or generated field '%s': %s"%(
						failure.value.colName, failure.getErrorMessage())))
		else:
			api.ui.notifyFailure(failure)
			return failure
		return self.ivoform.errors

	def getChild(self, name, request):
		if name==b"confirm":
			return ConfirmationPage(
				request.popSegments(None), request, self.service)
		raise api.UnknownURI("VOiDOI doesn't support that action.")


def parseCredentials(srcPath):
	"""parses the credentials file and returns a pair of prefix, auth.

	The credentials file is parsed using a keyValueGrammar, with keys
	prefix, user, password expected.
	"""
	grammar = api.makeStruct(kvgrammar.KeyValueGrammar)
	grammar.rowIterator.notify = False
	with open(srcPath) as f:
		rawRec = next(iter(grammar.parse(srcPath)))
	try:
		return rawRec["prefix"], (rawRec["user"], rawRec["password"]
			), rawRec["endpoint"]
	except KeyError as exc:
		raise api.ReportableError("credentials missing %s key"%exc)


def initModule(**kwargs):
	"""Sets up DATACITE_INTERFACE.

	This is a global singleton used to interface with datacite.  For testing,
	this interaction can be suppressed by passing testing="True" to this
	function.

	Equivalently, you can have an empty value to endpoint in credentials.txt.
	"""
	global DATACITE_INTERFACE
	modName = RD.getAbsPath("doitransform/oai2datacite")
	oai2datacite, _ = utils.loadPythonModule(modName)

	prefix, auth, endpoint = parseCredentials(RD.getAbsPath("credentials.txt"))

	if kwargs.get("testing"):
		endpoint = ""

	DATACITE_INTERFACE = oai2datacite.DataciteInterface(
		datacite_endpoint=endpoint,
		doi_prefix=prefix,
		auth=auth,
		xslt_source=RD.getAbsPath("doitransform/vor-to-doi.xslt"))


if __name__=="__main__":
	RD = api.getRD("voidoi/q")
	initModule()
	with api.getTableConn() as conn:
		doiTable = api.TableForDef(RD.getById("dois"), connection=conn)
		makeABackup(doiTable)
