"""
The coverage RD element.

Most code to obtain coverage information from actual resources is in
gavo.user.info.
"""

#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.

import re

from gavo import base
from gavo import stc
from gavo import utils
from gavo.rscdef import rdinj
from gavo.utils import pgsphere

class FloatInterval(base.AtomicAttribute):
	"""An attribute keeping a floating-point attribute.

	The literal of these is as in VOTable: a whitespace-separated pair
	for decimal floating-point literals.
	"""
	typeDesc_ = "A float interval (i.e., a space-separated pair of floats)"

	def parse(self, value):
		try:
			lower, upper = [float(f) for f in value.split()]
			if lower>upper:
				raise base.LiteralParseError(self.name_, value,
					hint="In intervals, the lower value must be smaller or equal"
						" to the upper one.")
			return lower, upper
		except (ValueError, TypeError):
			raise base.LiteralParseError(self.name_, value,
				hint="Intervals are represented as two floating-point literals"
				" separated by whitespace.")
	
	def unparse(self, value):
		return "%.10g %.10g"%value


class FloatOrDateInterval(FloatInterval):
	"""An attribute keeping a temporal interval as MJD.

	On literal input, we also allow ISO-like timestamps.  You cannot
	mix MJD and ISO, though.
	"""
	typeDesc_ = "A float or ISO timestamp interval (values separated by space)"
	def parse(self, value):
		try:
			return FloatInterval.parse(self, value)
		except base.LiteralParseError:
			try:
				lower, upper = [stc.dateTimeToMJD(utils.parseISODT(f))
					for f in value.split()]
				if lower>upper:
					raise base.LiteralParseError(self.name_, value,
						hint="In intervals, the lower value must be smaller or equal"
							" to the upper one.")
				return lower, upper
			except (ValueError, TypeError):
				raise base.LiteralParseError(self.name_, value,
					hint="Time intervals are represented as two floating-point or"
					" ISO timestamp literals separated by whitespace.")


class SpectralInterval(FloatInterval):
	"""An attribute giving the spectral coverage.

	On input, you can add unit tags in square brackets (``21[cm]``).
	Without a unit, this is in Joules of energy.
	"""
	typeDesc_ = ("A pair of floats with optional units in square brackets;"
		" unitless is Joule.  Lower/higher will be autoswapped.")

	def _parseSingle(self, value):
		mat = re.match(rf"\s*({utils.floatRE})\s*\[([^]]+)\]\s*$", value)
		if not mat:
			raise ValueError(f"{value} isn't val[unit]")

		rawVal = float(mat.group(1))
		return eval(base.getSpecExpr(mat.group(2), "J").format(rawVal))

	def parse(self, value):
		try:
			low, high = FloatInterval.parse(self, value)
		except base.LiteralParseError:
			try:
				lowS, highS = value.split()
				low = self._parseSingle(lowS)
				high = self._parseSingle(highS)
			except Exception as ex:
				raise base.LiteralParseError(self.name_, value,
					hint="This is a pair of float/spectral unit in square brackets each"
						f" ({ex})")
		return min(low, high), max(low, high)


class Ranges(base.ListOfAtomsAttribute):
	"""An attribute definition for a list of intervals.
	"""
	typeDesc_ = "A sequence of intervals (a space-separated pair of floats)"

	def __init__(self, name, intervalAttr, default=[], **kwargs):
		base.ListOfAtomsAttribute.__init__(
			self, name, default, intervalAttr, **kwargs)
	
	def feedFromArray(self, instance, arr):
		"""defines the intervals from a flat array by constructing
		each interval from consecuive pairs.
		"""
		self.feedObject(instance,
			list(zip(arr[0::2], arr[1::2])))
	

class Updater(base.Structure):
	"""Information on where and how to update a piece of coverage information.
	"""
	name_ = "updater"

	# this stuff is interpreted by user.limits.iterCoverageItems
	_sourceTable = base.ReferenceAttribute("sourceTable",
		default=base.NotGiven,
		description="A table from which to compute coverage by default.",
		copyable=True)
	_spaceTable = base.ReferenceAttribute("spaceTable",
		default=base.NotGiven,
		description="A table from which to compute spatial coverage (overrides"
		" sourceTable).",
		copyable=True)
	_timeTable = base.ReferenceAttribute("timeTable",
		default=base.NotGiven,
		description="A table from which to compute temporal coverage (overrides"
		" sourceTable)",
		copyable=True)
	_spectralTable = base.ReferenceAttribute("spectralTable",
		default=base.NotGiven,
		description="A table from which to compute spectral coverage (overrides"
		" sourceTable)",
		copyable=True)
	_mocOrder = base.IntAttribute("mocOrder",
		default=6,
		description="Maximal HEALpix order to use in coverage MOCs (6 is about"
			" a degree resolution, each additional point doubles resolution).",
		copyable=True)


class Coverage(base.Structure):
	"""The coverage of a resource.

	For now, this is attached to the complete resource rather than the table,
	since this is where it sits in VOResource.  DaCHS *could* be a bit
	more flexible, allowing different coverages per publish element.  It
	is not right now, though.

	Note: Technically, this will introduce or amend the coverage meta
	element.  The information given here will be masked if you define
	a coverage meta on the service or table level.  Just do not do that.
	"""
	name_ = "coverage"
	
	_updater = base.StructAttribute("updater",
		childFactory=Updater,
		description="Rules for automatic computation or updating of"
			" coverage information.",
		default=base.NotGiven)
	_spectral = Ranges("spectral",
		SpectralInterval(
			"spectral", description="A wavelength interval"),
		description="Interval(s) of spectral coverage, in Joules of"
		" BARYCENTER vacuum messenger particle energy; add '[unit]' to"
		" specify in other units (wavelength, frequency, and energy in VOUnit"
		" are supported).")
	_temporal = Ranges("temporal",
		FloatOrDateInterval(
			"temporal", description="A time interval"),
		description="Interval(s) of temporal coverage, in MJD"
		" (for TT BARYCENTER).")
	_spatial = base.UnicodeAttribute("spatial",
		default=base.NotGiven,
		description="A MOC in ASCII representation giving the ICRS coverage"
		" of the resource",
		expand=True)
	_fallbackTo = base.UnicodeAttribute("fallbackTo",
		default=base.NotGiven,
		description="Take coverage information from this RD in case"
			" none is given locally.  It is not an error if no such"
			" coverage information exists either.  Use this when a service"
			" re-exposes data from another RD, as in sitewide siap2 and obscore,"
			" so you only have to compute the coverage once.",
			copyable=True)

	def expand(self, value):
		return self.parent.expand(value)

	def completeElement(self, ctx):
		super().completeElement(ctx)

		fallbackRecords = {}
		if self.fallbackTo:
			with base.getTableConn() as conn:
				try:
					fallbackRecords = rdinj._getRecordsFor(
						self.fallbackTo, "dc.rdmeta", conn)[0]
				except IndexError:
					# no coverage from fallbackTo, either; keep the empty dict.
					pass

		if self.spatial is base.NotGiven:
			inj = ctx.getInjected("spatial_coverage",
				fallbackRecords.get("spatial"))
			if inj:
				self.feed("spatial", inj)

		if not self.spectral:
			inj = ctx.getInjected("spectral_coverage",
				fallbackRecords.get("spectral"))
			if inj:
				self._spectral.feedFromArray(self, inj)

		if not self.temporal:
			inj = ctx.getInjected("temporal_coverage",
				fallbackRecords.get("temporal"))
			if inj:
				self._temporal.feedFromArray(self, inj)

		if self.spatial is base.NotGiven:
			self.parsedMOC = None
		else:
			self.parsedMOC = pgsphere.SMoc.fromASCII(self.spatial)
	
	def onParentComplete(self):
		if self.spatial is not base.NotGiven:
			self.parent.addMeta("coverage.spatial", self.spatial)

		for interval in self.temporal:
			self.parent.addMeta("coverage.temporal",
				self._temporal.itemAttD.unparse(interval))

		for interval in self.spectral:
			self.parent.addMeta("coverage.spectral",
				self._spectral.itemAttD.unparse(interval))
