"""
The form renderer is the standard renderer for web-facing services.
"""

#c Copyright 2008-2025, the GAVO project <gavo@ari.uni-heidelberg.de>
#c
#c This program is free software, covered by the GNU GPL.  See the
#c COPYING file in the source distribution.


from twisted.internet import defer
from twisted.web import server
from twisted.web import template
from twisted.web.template import tags as T

from gavo import base
from gavo import formats
from gavo import svcs
from gavo.base import typesystems
from gavo import formal
from gavo.formal import nevowc
from gavo.svcs import customwidgets
from gavo.svcs import inputdef
from gavo.svcs import streaming
from gavo.svcs import weberrors
from gavo.web import grend
from gavo.web import serviceresults


class ToFormalConverter(typesystems.FromSQLConverter):
	"""is a converter from SQL types to Formal type specifications.

	The result of the conversion is a tuple of formal type and widget factory.
	"""
	typeSystem = "Formal"
	simpleMap = {
		"smallint": (formal.Integer, formal.TextInput),
		"integer": (formal.Integer, formal.TextInput),
		"int": (formal.Integer, formal.TextInput),
		"bigint": (formal.Integer, formal.TextInput),
		"real": (formal.Float, formal.TextInput),
		"float": (formal.Float, formal.TextInput),
		"boolean": (formal.Boolean, formal.Checkbox),
		"double precision": (formal.Float, formal.TextInput),
		"double": (formal.Float, formal.TextInput),
		"text": (formal.String, formal.TextInput),
		"unicode": (formal.String, formal.TextInput),
		"char": (formal.String, formal.TextInput),
		"date": (formal.Date, formal.widgetFactory(formal.DatePartsInput,
			twoCharCutoffYear=50, dayFirst=True)),
		"time": (formal.Time, formal.TextInput),
		"timestamp": (formal.Date, formal.widgetFactory(formal.DatePartsInput,
			twoCharCutoffYear=50, dayFirst=True)),
		"vexpr-float": (formal.String, customwidgets.NumericExpressionField),
		"vexpr-date": (formal.String, customwidgets.DateExpressionField),
		"vexpr-string": (formal.String, customwidgets.StringExpressionField),
		"vexpr-mjd": (formal.String, customwidgets.DateExpressionField),
		"pql-string": (formal.String, formal.TextInput),
		"pql-int": (formal.String, formal.TextInput),
		"pql-float": (formal.String, formal.TextInput),
		"pql-date": (formal.String, formal.TextInput),
		"file": (formal.File, None),
		"raw": (formal.String, formal.TextInput),
	}

	def convert(self, type, xtype=None):
		if xtype=="interval":
			baseType, baseWidget = self.convert(type.rsplit('[', 1)[0])
			return (lambda **kw: customwidgets.PairOf(baseType, **kw),
				customwidgets.Interval)

		try:
			return typesystems.FromSQLConverter.convert(self, type)
		except base.ConversionError:
			raise

	def mapComplex(self, type, length):
		if type in self._charTypes:
			return formal.String, formal.TextInput
		
		if length!='1' and type in self.simpleMap:
			ftype, _ = self.simpleMap[type]
			class S(formal.Sequence):
				type = ftype
				instance = ftype()
			return S, formal.TextInput


sqltypeToFormal = ToFormalConverter().convert


def _getFormalType(inputKey):
	return sqltypeToFormal(inputKey.type, xtype=inputKey.xtype
		)[0](required=inputKey.required)


def _makeWithPlaceholder(origWidgetFactory, newPlaceholder):
	"""helps _addPlaceholder to keep the namespaces sane.
	"""
	if newPlaceholder is None:
		return origWidgetFactory

	class widgetFactory(origWidgetFactory):
		placeholder = newPlaceholder
	return widgetFactory


def _getWidgetFactory(inputKey):
	if not hasattr(inputKey, "_widgetFactoryCache"):
		widgetFactory = inputKey.widgetFactory

		if widgetFactory is None:
			if inputKey.isEnumerated():
				widgetFactory = customwidgets.EnumeratedWidget(inputKey)
			else:
				widgetFactory = sqltypeToFormal(inputKey.type, inputKey.xtype)[1]

		if isinstance(widgetFactory, str):
			widgetFactory = customwidgets.makeWidgetFactory(widgetFactory)

		inputKey._widgetFactoryCache = _makeWithPlaceholder(widgetFactory,
			inputKey.getProperty("placeholder", None))
	return inputKey._widgetFactoryCache


def getFieldArgsForInputKey(inputKey):
	"""returns a dictionary of keyword arguments for gavo.formal
	addField from a DaCHS InputKey.
	"""
	# infer whether to show a unit and if so, which
	unit = ""
	if inputKey.type!="date":  # Sigh.
		unit = inputKey.inputUnit or inputKey.unit or ""
		if unit:
			unit = " [%s]"%unit
	label = inputKey.getLabel()

	res = {
		"name": inputKey.name,
		"type": _getFormalType(inputKey),
		"widgetFactory": _getWidgetFactory(inputKey),
		"label": label+unit,
		"description": inputKey.description,
		"cssClass": inputKey.getProperty("cssClass", None),}

	if inputKey.values and inputKey.values.default:
		res["default"] = str(inputKey.values.default)
	if inputKey.value:
		res["default"] = str(inputKey.value)

	return res


class MultiField(formal.Group, template.Element):
	"""A "widget" containing multiple InputKeys (i.e., formal Fields) in
	a single line.
	"""

	loader = template.TagLoader(
		T.div(class_=template.slot("class"), render="multifield")[
			T.label(for_=template.slot('id'))[template.slot('label')],
			T.div(class_="multiinputs", id=template.slot('id'),
				render="childFields"),
			T.div(class_='description')[template.slot('description')],
			template.slot('message')])

	@template.renderer
	def childFields(self, request, tag):
		formData = self.form.data
		formErrors = self.form.errors

		for field in self.items:
			widget = field.makeWidget()
			if field.type.immutable:
				render = widget.renderImmutable
			else:
				render = widget.render
			cssClass = " ".join(s for s in (field.cssClass, "inmulti") if s)
			
			if formErrors:
				formData = request.args

			tag[
				T.span(class_=cssClass)[
					render(request, field.key, formData, formErrors)(
						class_=cssClass, title=field.description or "")]]
		return tag

	def _getMessageElement(self, request):
		errors = []
		formErrors = self.form.errors
		if formErrors is not None:
			for field in self.items:
				err = formErrors.getFieldError(field.key)
				if err is not None:
					errors.append(err.message)
		if errors:
			return T.div(class_='message')["; ".join(errors)]
		else:
			return ''

	@template.renderer
	def multifield(self, request, tag):
		errMsg = self._getMessageElement(request)
		tag.fillSlots(description=self.description,
			label=self.label or "",
			id="multigroup-"+self.key,
			message=errMsg)
		if errMsg:
			tag.fillSlots(**{"class":'field error'})
		else:
			tag.fillSlots(**{"class":'field'})
		return tag


class FormMixin:
	"""A mixin to produce input forms for services and display
	errors within these forms.
	"""
	parameterStyle = "form"

	def _handleInputErrors(self, failure, request):
		"""goes as an errback to form handling code to allow correction form
		rendering at later stages than validation.
		"""
		if isinstance(failure.value, formal.FormError):
			self.genForm.errors.add(failure.value)
		elif isinstance(failure.value, base.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.genForm.items.getItemByName(failedField)
				self.genForm.errors.add(formal.FieldValidationError(
					str(failure.getErrorMessage()), failedField))
			except KeyError: # Failing field cannot be determined
				self.genForm.errors.add(formal.FormError("Problem with input"
					" in the internal or generated field '%s': %s"%(
						failure.value.colName, failure.getErrorMessage())))
		else:
			base.ui.notifyFailure(failure)
			return failure
		return self.genForm.errors

	def _addDefaults(self, request, form, additionalDefaults):
		"""adds defaults from additionalDefaults.

		This is to let operators preset fields.
		"""
		if request is None:
			args = ()
		else:
			args = request.strargs

		for key, value in list(additionalDefaults.items()):
			if not key in args:
				form.data[key] = value

	def _addInputKey(self, form, container, inputKey):
		"""adds a form field for an inputKey to the form.
		"""
		if inputKey.values and inputKey.values.default:
			self._defaultsForForm[inputKey.name] = inputKey.values.default

		if inputKey.hasProperty("defaultForForm"):
			self._defaultsForForm[inputKey.name
				] = inputKey.getProperty("defaultForForm")

		container.addField(**getFieldArgsForInputKey(inputKey))

	def _groupQueryFields(self, inputTable):
		"""returns a list of "grouped" inputKey names from inputTable.

		The idea here is that you can define "groups" in your input table.
		Each such group can contain paramRefs.  When the input table is rendered
		in HTML, the grouped fields are created in a formal group.  To make this
		happen, they may need to be resorted.  This happens in this function.

		The returned list contains strings (parameter names), groups (meaning
		"start a new group") and None (meaning end the current group).

		This is understood and used by _addQueryFields.
		"""
		groupedKeys = {}
		for group in inputTable.groups:
			for ref in group.paramRefs:
				groupedKeys[ref.key] = group

		inputKeySequence, addedNames = [], set()
		for inputKey in inputTable.inputKeys:
			thisName = inputKey.name

			if thisName in addedNames:
				# part of a group and added as such
				continue

			newGroup = groupedKeys.get(thisName)
			if newGroup is None:
				# not part of a group
				inputKeySequence.append(thisName)
				addedNames.add(thisName)
			else:
				# current key is part of a group: add it and all others in the group
				# enclosed in group/None.
				inputKeySequence.append(newGroup)
				for ref in groupedKeys[inputKey.name].paramRefs:
					inputKeySequence.append(ref.key)
					addedNames.add(ref.key)
				inputKeySequence.append(None)
		return inputKeySequence

	def _addQueryFieldsForInputTable(self, form, inputTable):
		"""generates input fields form the parameters of inputTable, taking
		into account grouping if necessary.
		"""
		containers = [form]
		for item in self._groupQueryFields(inputTable):
			if item is None:  # end of group
				containers.pop()

			elif isinstance(item, str):  # param reference
				self._addInputKey(form, containers[-1],
					inputTable.inputKeys.getColumnByName(item))

			else:
				# It's a new group -- if the group has a "style" property and
				# it's "compact", use a special container form formal.
				if item.getProperty("style", None)=="compact":
					groupClass = MultiField
				else:
					groupClass = formal.Group

				containers.append(
					form.add(groupClass(item.name, description=item.description,
						label=item.getProperty("label", None),
						cssClass=item.getProperty("cssClass", None),
						form=form)))

	def _addQueryFields(self, form):
		"""adds the inputFields of the service to form, setting proper defaults
		from the field or from data.
		"""
		# we have an inputTable.  Handle groups and other fancy stuff
		self._addQueryFieldsForInputTable(form,
			self.service.getCoreFor(self, self.queryMeta).inputTable)

		# and add the service keys manually as appropriate
		for item in inputdef.filterInputKeys(self.service.serviceKeys,
				self.name, inputdef.getRendererAdaptor(self)):
			self._addInputKey(form, form, item)

	def _addMetaFields(self, form):
		"""adds fields to choose output properties to form.
		"""
		try:
			if self.service.core.wantsTableWidget():
				form.addField("_DBOPTIONS", svcs.FormalDict,
					formal.widgetFactory(svcs.DBOptions, self.service, self.queryMeta),
					label="Table")
		except AttributeError: # probably no wantsTableWidget method on core
			pass

	def _getFormLinks(self):
		"""returns stan for widgets building GET-type strings for the current
		form content.
		"""
		return T.div(class_="formLinks")[
				T.a(href="", class_="resultlink", onmouseover=
						"this.href=makeResultLink(getEnclosingForm(this))",
						# Ugh -- when the form is flattened, the formal.FormRenderer
						# is the renderFactory, and it doesn't know iflinkable.
						# No idea how we'd do this in an XML template.  Hm.
						render=self.iflinkable)
					["[Result link]"],
				" ",
				T.a(href="", class_="resultlink", onmouseover=
						"this.href=makeBookmarkLink(getEnclosingForm(this))")[
					T.img(src=base.makeSitePath("/static/img/bookmark.png"),
						class_="silentlink", title="Link to this form", alt="[bookmark]")
				],
			]

	def form_genForm(self, request=None, data=None):
		# this is an accumulator for defaultForForm items processed; this
		# is used below to pre-fill forms without influencing service
		# behaviour in the absence of parameters.
		self._defaultsForForm = {}

		form = formal.Form()
		self._addQueryFields(form)
		self._addMetaFields(form)
		self._addDefaults(request, form, self._defaultsForForm)

		if (self.name=="form"
				and self.service.getProperty("fixedFormat", "")!="True"):
			form.addField("_OUTPUT", formal.String,
				formal.widgetFactory(serviceresults.OutputFormat,
					self.service, self.queryMeta),
				label="Output format")

		form.actionURL = self.service.getURL(self.name)
		form.addAction(self.submitAction, label="Go")
		form.actionMaterial = self._getFormLinks()
		self.genForm = form
		return form

	def _setResult(self, result):
		"""sets result as the renderer's internal result

		This is the default callback of the submit action and is
		required by various data and render functions.  Once the
		renderer is here, non-rsc.Data results have been dealt with already.
		"""
		self.result = result
		return result

	def data_resultmeta(self, request, tag):
		resultmeta = {
			"itemsMatched": str(self.queryMeta.get("Matched",
				len(self.result.getPrimaryTable()))),
			"message": "",
		}
		return resultmeta

	def data_inputRec(self, request, tag):
		if self.queryMeta["inputTable"]:
			return self.queryMeta["inputTable"].getParamDict()
		else:
			return {}

	def data_table(self, request, tag):
		return self.result.getPrimaryTable()

	def data_tableWithRole(self, role):
		"""returns the table with role.

		If no such table is available, this will return an empty string.
		"""
		def _(req, tag):
			try:
				return self.result.getTableWithRole(role)
			except (AttributeError, base.DataError):
				return ""

		return _

	def _realSubmitAction(self, request, form, data):
		"""helps submitAction by doing the real work.

		It is here so we can add an error handler in submitAction.
		"""
		if self.queryMeta.get("format") in ("HTML", None):
			resultWriter = self
		else:
			resultWriter = serviceresults.getFormat(self.queryMeta["format"])

		if resultWriter.compute:
			d = self.runAsyncWithFormalData(data, request)
		else:
			d = defer.succeed(None)

		d.addCallback(self._setResult)
		if resultWriter is not self:
			d.addCallback(resultWriter._formatOutput, request, self.queryMeta)
		return d

	def submitAction(self, request, form, data):
		"""executes the service.

		This is a callback for the formal form.
		"""
		return defer.maybeDeferred(
			self._realSubmitAction, request, form, data
				).addErrback(self._handleInputErrors, request)


class FormRenderer(formal.ResourceWithForm,
		FormMixin,
		grend.CustomTemplateMixin,
		grend.HTMLResultRenderMixin,
		grend.ServiceBasedPage):
	"""The "normal" renderer within DaCHS for web-facing services.

	It will display a form and allow outputs in various formats.

	It also does error reporting as long as that is possible within
	the form.
	"""
	name = "form"
	runOnEmptyInputs = False
	processOnGET = True
	compute = True
	defaultOutputFormat = "HTML"

	def __init__(self, request, service):
		grend.ServiceBasedPage.__init__(self, request, service)
		if "form" in self.service.templates:
			self.customTemplate = self.service.getTemplate("form")

		# enable special handling if I'm rendering fixed-behaviour services
		# (i.e., ones that never have inputs) XXX TODO: Figure out where I used this and fix that to use the fixed renderer (or whatever)
		if not self.service.getInputKeysFor(self):
			self.runOnEmptyInputs = True

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

	@classmethod
	def isCacheable(self, segments, request):
		return segments==()

	def render(self, request):
		if self.runOnEmptyInputs:
			request.args[formal.FORMS_KEY] = [b"genForm"]
		# we need to override the normal render callback here because we
		# may actually stream out completely different stuff, and
		# if we do, these need to finish the request.  But that's
		# all arranged up in the submit action, so all we need to
		# to here is ignore things.
		return formal.ResourceWithForm.render(self,
			request,
			customCallback=lambda res: self._formatOutput(res, request))

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

	handleError = crash

	def _formatOutput(self, res, request):
		"""delivers the whole document, hopefully not blocking too much.
		"""
		if res==server.NOT_DONE_YET:
			# _realSubmitAction has arranged for someone else to write the result,
			# and that's what its render method returned
			return res

		if isinstance(res, tuple):
			# core returned a complete document (mime and string)
			mime, payload = res
			request.setHeader("content-type", mime)
			if not mime.startswith("text/html"):
				request.setHeader('content-disposition',
					'attachment; filename=result%s'%formats.getExtensionFor(mime))
			return streaming.streamOut(lambda f: f.write(payload),
				request, self.queryMeta)

		else:
			if "response" in self.service.templates:
				self.customTemplate = self.service.getTemplate("response")

			nevowc.TemplatedPage.renderDocument(self, request
				).addErrback(weberrors.renderDCErrorPage, request
				).addCallback(request.finishCallback)
			return server.NOT_DONE_YET

	def getChild(self, name, request):
		if name==b"":
			# redirect so we're not a directory resource
			raise svcs.WebRedirect(request.path.rstrip(b"/"))
		else:
			raise svcs.UnknownURI("Forms have no children")

	defaultLoader = svcs.loadSystemTemplate("defaultresponse.html")
