"""
Attribute definitions for structures.

These are objects having at least the following attributes and methods:

	- name -- will become the attribute name on the embedding class
	- parseName -- the name of the XML/event element they can parse.  This
		usually is identical to name, but may differ for compound attributes
	- default -- may be Undefined, otherwise a valid value of the
		expected type
	- description -- for user documentation
	- typeDesc -- describing the content; this is usually a class
		attribute and intended for user documentation
	- before -- the name of another attribute the attribute should precede
		in XML serializations.  It is not an error to refer to an attribute
		that does not exist.
	- feedObject(instance, ob) -> None -- adds ob to instance's attribute value.
		This will usually just result in setting the attribute; for compound
		attributes, this may instead append to a list, add to a set, etc.
	- getCopy(instance, newParent, ctx) -> value -- returns the python value
		of the attribute in instance, copying mutable values (deeply) in the
		process.
	- iterParentMethods() -> iter((name, value)) -- iterates over methods
		to be inserted into the parent class.
	- makeUserDoc() -> returns some RST-valid string describing what the object
		is about (or None, in which case the attribute is hidden in the
		documentation).

AtomicAttributes, defined as those that are parsed from a unicode literal,
add methods

	- feed(ctx, instance, literal) -> None -- arranges for literal to be parsed
		and passed to feedObject.  ctx is a parse context.  The built-in method
		does not expect anything from this object, but structure has a default
		implementation containing an idmap and a property registry.
	- parse(self, value) -> anything -- returns a python value for the
		unicode literal value
	- unparse(self, value) -> unicode -- returns a unicode object representing
		value and parseable by parse to that value.

Attributes can have aliases; they are always called in XML whatever their name
is.  However, sometimes that's a bit odd; consider an list-valied attribute
``groups``; an item from it should certainly be called ``group`` on input.  For
situations like that, you can furnish attributes with a list of strings in
``aliases``.  Any alias, as well as the original name, is then legal
on input (and for attribute access, too, but don't rely on that).  I
am considering whether to add some code to make the attribute name
itself unavailable as input names when there are aliases, but that's
probably trickier than it's worth.

This is not enough for complex attributes.  More on those in the
base.complexattrs module.

AttributeDefs *may* have a validate(instance) method.  Structure instances
will call them when they are done building.  They should raise
LiteralParseErrors if it turns out a value that looked right is not after
all (in a way, they could catch validity rather than well-formedness violations,
but I don't think this distinction is necessary here).

See structure on how to use all these.
"""

#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 os
import re

from gavo import utils
from gavo.utils import Undefined
from gavo.base import literals
from gavo.base.common import LiteralParseError, NotGiven


from gavo.utils.dachstypes import (
	Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Optional, Set,
	Structure, StructParseContext, Tuple, Union)

class Recursive:
	"""a sentinel class for attributes embedding structures to signify
	they embed the structure embedding them.
	"""
	name_ = "RECURSIVE"


class Computed:
	"""A sentinel class for computed (property) defaults.

	Use this to construct AttributeDefs with defaults that are properties
	to inhibit assigning to them.  This should only be required in calls
	of the superclass's init.
	"""


# Values for which no special stringification for docs is attempted
_NULL_LIKE = set([None, Undefined, NotGiven])
_NullLikeType = Union[None, utils.NotGivenType, utils.UndefinedType]


class AttributeDef:
	"""The base class for all attribute definitions.

	See the module docstring for general information.

	The data attribute names have all an underscore added to avoid name
	clashes -- structures should have about the same attributes and may
	want to have managed attributes called name or description.

	When constructing AttributeDefs, you should only use keyword
	arguments, except for name (the first argument).

	Note that an AttributeDef might be embedded by many instances.  So,
	you must *never* store any instance data in an AttributeDef (unless
	it's really a singleton, of course).
	"""

	typeDesc_: ClassVar = "unspecified, invalid"
	# A name into gavo.utils.dachstypes for what values can be assigned
	# here (we probably should be smart here an add "Optional" as per
	# default?)
	typingType_: ClassVar = "Any"

	def __init__(self,
			name: str,
			default: Any = None,
			description: str = "Undocumented",
			copyable: bool = False,
			aliases: Iterable[str] = (),
			callbacks: Optional[List[str]] = None,
			before: Optional[str] = None) -> None:
		self.name_, self.description_ = name, description
		self.copyable = copyable
		self.aliases = aliases
		self.callbacks = callbacks
		self.before = before
		if default is not Computed:
			self.default_ = default

	def iterParentMethods(self
			) -> Iterator[Tuple[str, Union[Callable, property]]]:
		"""returns an iterator over (name, method) pairs that should be
		inserted in the parent class.
		"""
		return iter([])

	def doCallbacks(self, instance: Structure, value: Any) -> None:
		"""should be called after feedObject has done its work.
		"""
		if self.callbacks:
			for cn in self.callbacks:
				getattr(instance, cn)(value)

	def feedObject(self, instance: Structure, value: Any): # pragma: no cover
		raise NotImplementedError("%s doesn't implement feeding objects"%
			self.__class__.__name__)

	def feed(self, ctx, instance: Structure, value: Any): # pragma: no cover
		raise NotImplementedError("%s doesn't implement feeding literals"%
			self.__class__.__name__)

	def getCopy(self,             # pragma: no cover
			instance: Structure,
			newParent: Structure,
			ctx: StructParseContext) -> Any:
		raise NotImplementedError("%s cannot be copied."%
			self.__class__.__name__)

	def makeUserDoc(self) -> Optional[str]:
		return "**%s** (%s; defaults to %s) -- %s"%(
			self.name_, self.typeDesc_, repr(self.default_), self.description_)


class AtomicAttribute(AttributeDef):
	"""A base class for attributes than can be immediately parsed
	and unparsed from strings.

	They need to provide a parse method taking a unicode object and
	returning a value of the proper type, and an unparse method taking
	a value of the proper type and returning a unicode string suitable
	for parse.

	Note that you can, of course, assign to the attribute directly.
	If you assign crap, the unparse method is explicitly allowed
	to bomb in random ways; it just has to be guaranteed to work
	for values coming from parse (i.e.: user input is checked,
	programmatic input can blow up the thing; I consider this
	pythonesque :-).
	"""
	def parse(self, value: str) -> Any: # pragma: no cover
		"""returns a typed python value for the string representation value.

		value can be expected to be a unicode string.
		"""
		raise NotImplementedError("%s does not define a parse method"%
			self.__class__.__name__)

	def unparse(self, value: Any) -> Optional[str]: # pragma: no cover
		"""returns a typed python value for the string representation value.

		value can be expected to be a unicode string.
		"""
		raise NotImplementedError("%s does not define an unparse method"%
			self.__class__.__name__)

	def feed(self,
		ctx: StructParseContext, instance: Structure, value: str) -> None:
		self.feedObject(instance, self.parse(value))

	def feedObject(self, instance: Structure, value: Any) -> None:
		setattr(instance, self.name_, value)
		self.doCallbacks(instance, value)

	def getCopy(self,
			instance: Structure,
			newParent: Structure,
			ctx: Optional[StructParseContext]) -> Any:
		# We assume atoms are immutable here
		return getattr(instance, self.name_)

	def makeUserDoc(self) -> Optional[str]:
		default = self.default_
		try:
			if default not in _NULL_LIKE:
				default = self.unparse(default)
		except TypeError:  # unhashable defaults can be unparsed
			default = self.unparse(default)
		return "**%s** (%s; defaults to %s) -- %s"%(
			self.name_, self.typeDesc_, repr(default), self.description_)


class RawAttribute(AtomicAttribute):
	"""An attribute definition that does no parsing at all.

	This is only useful in "internal" structures that never get
	serialized or deserialized.
	"""
	def parse(self, value: str) -> Any:
		return value
	
	def unparse(self, value: Any) -> Any: #type: ignore
		return value


class PostprocessedTextAttribute(AtomicAttribute):
	"""An abstract base for attributes that may need to do some
	text processing.
	"""
	def __init__(self, name: str, **kwargs: Any) -> None:
		self.nullLiteral = kwargs.pop("null", "__NULL__")
		self.strip = kwargs.pop("strip", False)
		self.expand = kwargs.pop("expand", False)
		AtomicAttribute.__init__(self, name, **kwargs)

	def parse(self, value: Optional[str]) -> Any:
		if isinstance(value, bytes):
			value = value.decode("utf-8")

		if value==self.nullLiteral:
			return None
		if self.strip and value is not None:
			value = value.strip()
		return value

	def feed(self,
			ctx: StructParseContext,
			instance: Structure,
			value: str) -> None:
		# the type ignore here is because not all structs can expand
		# macros, and it's too hard for me to explain to mypy which are.
		if isinstance(value, str) and self.expand and "\\" in value:
			value = instance.expand(value)  # type: ignore
		self.feedObject(instance, self.parse(value))


class UnicodeAttribute(PostprocessedTextAttribute):
	"""An attribute definition for an item containing a string.

	This will decode bytes passed in assuming they're utf-8 and fail
	if they're not.  Unparsing will not bring them back to bytes.

	In addition to AtomicAttribute's keywords, you can use ``strip`` (default
	false) to have leading and trailing whitespace be removed on parse.
	(Unparsing will not add it back).

	You can also add ``expand`` (default False) to have UnicodeAttribute
	try and expand RD macros on the instance passed in.  This of course
	only works if the attribute lives on a class that is a MacroPackage.
	"""

	typeDesc_ = "unicode string"

	def parse(self, value: Optional[str]) -> Optional[str]:
		return PostprocessedTextAttribute.parse(self, value)

	def unparse(self, value: Optional[str]) -> Optional[str]:
		if value is None:
			if self.nullLiteral is None:
				raise ValueError("Unparse None without a null literal can't work.")
			return self.nullLiteral
		return value


class NWUnicodeAttribute(UnicodeAttribute):
	"""A UnicodeAttribute that has its whitespace normalized.

	Normalization consists of stripping whitespace at the ends and replacing
	any runs or internal whitespace by a single blank.  The whitespace
	will not be added back on unparsing.
	"""
	typeDesc_ = "whitespace normalized unicode string"

	def parse(self, value: Optional[str]) -> Optional[str]:
		value = UnicodeAttribute.parse(self, value)
		if value is None:
			return value
		return re.sub(r"\s+", " ", value.strip())


class FunctionRelativePathAttribute(UnicodeAttribute):
	"""A (utf-8 encoded) path relative to the result of some function
	at runtime.

	This is used to make things relative to config items.
	"""
	def __init__(self,
			name: str,
			baseFunction: Callable,
			default: Union[str, _NullLikeType] = None,
			description: str = "Undocumented",
			**kwargs: Any) -> None:
		kwargs["strip"] = kwargs.get("strip", True)
		UnicodeAttribute.__init__(self, name, default=default,
			description=description, **kwargs)
		self.baseFunction = baseFunction
		self.hiddenAttName = "_real_"+self.name_

	def parse(self, value: Optional[str]) -> Any:
		return value
	
	def unparse(self, value: Any) -> Optional[str]:
		return value

	def iterParentMethods(self):
		def computePath(instance: Structure
				) -> Union[str, None, utils.NotGivenType]:
			relative = getattr(instance, self.hiddenAttName)
			if relative is Undefined:
				raise utils.StructureError("Attribute %s is mandatory"%self.name_)
			if relative is NotGiven or relative is None:
				return relative

			return os.path.join(self.baseFunction(instance), relative)

		def setRelative(
				instance: Structure,
				value: Union[bool, _NullLikeType]) -> None:
			setattr(instance, self.hiddenAttName, value)

		yield (self.name_, property(computePath, setRelative))


class EnumeratedUnicodeAttribute(UnicodeAttribute):
	"""An attribute definition for an item that can only take on one
	of a finite set of values.
	"""
	def __init__(self,
			name: str,
			default: Union[str, _NullLikeType],
			validValues: List[str],
			**kwargs: Any) -> None:
		kwargs["strip"] = kwargs.get("strip", True)
		UnicodeAttribute.__init__(self, name, default=default, **kwargs)
		self.validValues = set(validValues)

	# I'm shutting up mypy here beause it disagrees with my
	@property
	def typeDesc_(self) -> str:  # type: ignore
		return "One of: %s"%", ".join(sorted(self.validValues))

	def parse(self, value: Optional[str]) -> Optional[str]:
		value = UnicodeAttribute.parse(self, value)
		if not value in self.validValues:
			raise LiteralParseError(self.name_, value,
				hint="Valid values include %s"%",".join(self.validValues))
		return value


class IntAttribute(AtomicAttribute):
	"""An attribute definition for integer attributes.
	"""

	typeDesc_ = "integer"

	def parse(self, value: Optional[str]) -> Optional[int]:
		try:
			if value is not None:
				return int(value)
		except ValueError:
			raise utils.logOldExc(
				LiteralParseError(self.name_, value, hint="Value must be an"
				" integer literal."))
		return None
	
	def unparse(self, value: Optional[int]) -> Optional[str]:
		return str(value)


class FloatAttribute(AtomicAttribute):
	"""An attribute definition for floating point attributes.
	"""

	typeDesc_ = "float"

	def parse(self, value: Optional[str]) -> Optional[float]:
		try:
			if value is not None:
				return float(value)
		except ValueError:
			raise utils.logOldExc(
				LiteralParseError(self.name_, value, hint="value must be a float"
					" literal"))
		return None	

	def unparse(self, value: Optional[float]) -> str:
		return str(value)


class BooleanAttribute(AtomicAttribute):
	"""A boolean attribute.

	Boolean literals are strings like True, false, on, Off, yes, No in
	some capitalization.
	"""
	typeDesc_ = "boolean"

	def parse(self, value: str) -> bool:
		try:
			return literals.parseBooleanLiteral(value)
		except ValueError:
			raise utils.logOldExc(LiteralParseError(
				self.name_,
				value,
				hint= "A boolean literal (e.g., True, False, yes, no)"
					" is expected here."))
		
	def unparse(self, value: bool) -> str:
		return {True: "True", False: "False"}[value]


class StringListAttribute(PostprocessedTextAttribute):
	"""An attribute containing a list of comma separated strings.

	The value is a list.  This is similar to a complexattrs.ListOfAtoms
	with UnicodeAttribute items, except the literal is easier to write
	but more limited.  Use this for the user's convenience.
	"""
	typeDesc_ = "Comma-separated list of strings"
	realDefault: List[str] = []

	def __init__(self, name: str, **kwargs: Any) -> None:
		if "default" in kwargs:
			self.realDefault = kwargs.pop("default")
		PostprocessedTextAttribute.__init__(
			self, name, default=Computed, **kwargs)

	def parse(self, value: Optional[str]) -> List[str]:
		value = PostprocessedTextAttribute.parse(self, value)
		if value is not None:
			return [str(item.strip())
				for item in value.split(",") if item.strip()]
		return []

	@property
	def default_(self) -> Any:
		try:
			return self.realDefault[:]
		except TypeError:  # Not iterable; that's the client's problem.
			return self.realDefault

	@staticmethod
	def _unparseSingle(v):
		if hasattr(v, "unparse"):
			# that's mainly for QuotedName-s
			return v.unparse()
		else:
			return v

	def unparse(self, value: Union[List[str], Set[str]]) -> str:
		return ", ".join(self._unparseSingle(v) for v in value)


class StringSetAttribute(StringListAttribute):
	"""A StringListAttribute, except the result is a set.
	"""
	# let me be lazy here and not introduce another intermediate abstract
	# class; it's too easy to reuse all of StringListAttribute

	realDefault: set = set() # type: ignore

	def parse(self, value: str) -> Set[str]: # type: ignore
		return set(StringListAttribute.parse(self, value))
	
	@property
	def default_(self) -> Union[Set, frozenset]:
		return self.realDefault.copy()


class IdMapAttribute(AtomicAttribute):
	"""An attribute allowing a quick specification of identifiers to
	identifiers.

	The literal format is <id>:<id>{,<id>:<id>},? with ignored whitespace.
	"""
	typeDesc_ = "Comma-separated list of <identifier>:<identifier> pairs"

	def parse(self, val: str) -> Dict[str, str]:
		if val is None:
			return None
		val = val.strip().rstrip(",")
		res = {}
		for part in val.split(","):
			try:
				left, right = part.split(":")
				res[left.strip()] = right.strip()
			except ValueError:
				raise LiteralParseError(self.name_, val,
					hint=f"The offending text in the value is '{part}'")
		return res

	def unparse(self, val: Optional[Dict[str, str]]) -> Optional[str]:
		if val is None:
			return None
		return ", ".join(["%s: %s"%(k, v) for k, v in val.items()])


class ActionAttribute(UnicodeAttribute):
	"""An attribute definition for attributes triggering a method call
	on the parent instance.
	
	They do create an attribute on parent which is None by default
	and the attribute value as a unicode string once the attribute
	was encountered.  This could be used to handle multiple occurrences
	but is not in this basic definition.
	"""
	def __init__(self,
			name: str,
			methodName: str,
			description: str = "Undocumented",
			**kwargs: Any) -> None:
		kwargs["strip"] = kwargs.get("strip", True)
		self.methodName = methodName
		UnicodeAttribute.__init__(self, name, default=None,
			description=description, **kwargs)
	
	def feed(self,
			ctx: StructParseContext,
			instance: Structure,
			value: str) -> None:
		UnicodeAttribute.feed(self, ctx, instance, value)
		getattr(instance, self.methodName)(ctx)
			

# __init__ does an import * from this.  You shouldn't.

__all__ = ["LiteralParseError", "Undefined", "UnicodeAttribute",
	"IntAttribute", "BooleanAttribute", "AtomicAttribute",
	"EnumeratedUnicodeAttribute", "AttributeDef", "Computed",
	"FunctionRelativePathAttribute",
	"StringListAttribute", "ActionAttribute", "FloatAttribute",
	"StringSetAttribute", "NotGiven", "IdMapAttribute",
	"NWUnicodeAttribute", "RawAttribute"]
