"""
General event handling.

Basically, everything roughly classified as user interaction should go
through this module.  gavo.base, on import, creates an instance of
EventDispatcher and installs it as base.ui.  The rest of the library
can then call methods of base.ui.

Clients can then register observers (probably derived from
base.observer.Observer) that subscribe to events and can display or
log them in some form appropriate to the client.
"""

#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 contextlib
import sys

from gavo import utils

from gavo.utils.dachstypes import (Any, BaseEventHandler, Dict,
	Iterator, List, Optional, SourceToken, Structure, Tuple)

# the cli sets this to true if exceptions should not be mutated.
PDB_ENABLED = False

class DispatcherType(type):
	"""A metaclass for dispatching of messages.

	Basically, you define methods called notify<whatever> in your class.
	For each of them, a subscribe<whatever> method is added.

	Then, when notify<whatever> is called, your defined method is called,
	and its result is then passed to all callbacks passed in through
	subscribe<whatever>.
	"""
	def __init__(cls, name: str, bases: Tuple[type, ...], dict: Dict):
		type.__init__(cls, name, bases, dict)
		cls.eventTypes: List[str] = []
		cls._makeNotifiers(dict)

	def _makeNotifier(cls, name: str, callable: BaseEventHandler) -> None:
		cls.eventTypes.append(name)
		def notify(self: Any, *args: Any, **kwargs: Any) -> Any:
			res = callable(self, *args, **kwargs)
			for callback in self.callbacks[name]:
				callback(res)
			return res

		def subscribe(self: Any, callback: BaseEventHandler) -> None:
			self.subscribe(name, callback)

		def unsubscribe(self: Any, callback: BaseEventHandler) -> None:
			self.unsubscribe(name, callback)

		setattr(cls, "notify"+name, notify)
		setattr(cls, "subscribe"+name, subscribe)
		setattr(cls, "unsubscribe"+name, unsubscribe)

	def _makeNotifiers(cls, dict: Dict[str, BaseEventHandler]):
		for name, val in dict.items():
			if name.startswith("notify"):
				cls._makeNotifier(name[6:], val)


class EventDispatcher(metaclass=DispatcherType):
	"""is the central event dispatcher.

	Events are posted by using notify* methods.  Various handlers can
	then attach to them.
	"""

	def __init__(self) -> None:
		# mypy 1.0.1 doesn't realise there's an attribute from the metaclass.
		self.callbacks: Dict[str, List[BaseEventHandler]
			] = dict((name, []) for name in self.eventTypes) # type: ignore
		self.sourceStack: List[Optional[str]] = [None]
		self.curSource: Optional[str] = None

		# statistics of dachs imp
		self.totalShippedOut = 0
		self.totalRead = 0
		# the last row fed in by a grammar
		self.lastRow: Optional[Dict[str, Any]] = None

		# the name of a long-running process (if any)
		self.longRunningProcess: Optional[Structure] = None

	@contextlib.contextmanager
	def suspended(self, evName: str) -> Iterator[None]:
		"""a context manager suspending notification for a specific event.

		This is mainly for use by test code that wants to avoid spilling
		too much junk into the log.

		One weak point here is that any subscriptions entered while notification
		is suspended are lost.  So: Don't suspend notifications for normal code.
		"""
		origCallbacks = self.callbacks[evName]
		self.callbacks[evName] = []
		try:
			yield
		finally:
			self.callbacks[evName] = origCallbacks

	def subscribe(self, evName: str, callback: BaseEventHandler) -> None:
		self.callbacks[evName].append(callback)

	def unsubscribe(self, evName: str, callback: BaseEventHandler) -> None:
		"""removes a callback from evName's callback list.

		It is not an error to unsubscribe a callback that's not subscribed.
		"""
		try:
			self.callbacks[evName].remove(callback)
		except ValueError:
			pass

	def notifyExceptionMutation(self, newExc: Exception
			) -> Tuple[Any, Exception]:
		"""is called when an exception is being handled by raising newExc.

		The callbacks are passed a pair of sys.exc_info() and newExc.
		"""
		return sys.exc_info(), newExc

	def logOldExc(self, newExc: Exception) -> Exception:
		"""notifies of and ExceptionMutation and returns newExc.
	
		This is just a convenience when mutating exceptions.
		"""
		if PDB_ENABLED:
			# leave original exception for the pdb
			raise
		self.notifyExceptionMutation(newExc)
		return newExc

	def notifyNewSource(self, sourceToken: SourceToken) -> str:
		"""is called when a new source is being operated on.

		The callbacks are passed some, hopefully useful, token string.  For
		file source, this is the file name, otherwise we try to make up
		something.

		As side effects, the curSource attribute is set to this value.
		"""
		sourceName = utils.makeSourceEllipsis(sourceToken)
		self.curSource = sourceName
		self.sourceStack.append(sourceName)
		return sourceName

	def notifySourceError(self) -> Optional[str]:
		"""is called when a parse error occurred in a source.

		The callbacks are passed the name of the failing source.
		"""
		if self.sourceStack:  # user-defined grammars may fail to push one
			lastSource = self.sourceStack.pop()
		else:
			lastSource = "Undefined"
		try:
			self.curSource = self.sourceStack[-1]
		except IndexError: # this would be an internal error...
			self.curSource = None
		return lastSource

	def notifySourceFinished(self) -> Optional[str]:
		"""is called when a source file has been processed.

		The curSource attribute is updated, and its old value is propagated
		to the callbacks.
		"""
		try:
			lastSource = self.sourceStack.pop()
			self.curSource = self.sourceStack[-1]
		except IndexError:
			# someone didn't notified us of a finish without telling us first
			# they started.  Don't fail because of this.
			lastSource = None
		return lastSource
	
	def notifyShipout(self, numItems: int) -> int:
		"""is called when certain table implementations store items.

		The number of items is passed on to the callbacks.  As a side effect,
		the instance variable totalShippedOut is adjusted.

		InMemoryTables don't call this right now and probably never will.
		"""
		self.totalShippedOut += numItems
		return numItems
	
	def notifyIncomingRow(self, row: Dict[str, Any]) -> Dict[str, Any]:
		"""is called when certain grammars yield a row to the DC's belly.

		The callbacks receive a reference to the row.  As a side effect,
		the instance variable totalRead is bumped up, and lastRow becomes
		the row passed in.

		To support this, RowIterators have to call this method in their
		_iterRows.  Most will do, DictlistGrammars, e.g., don't.
		"""
		self.totalRead += 1
		self.lastRow = row
		return row

	def notifyIndexCreation(self, indexName: str) -> str:
		"""is called when an index on a DB table is created.

		The callbacks receive the index name.
		"""
		return indexName

	def notifyScriptRunning(self, script: Structure, source: Structure
			) -> Tuple[Structure, Structure]:
		"""is called when a script is being started.

		The callback receives a pair of a scripting.Script and
		the object the script sits on.
		"""
		return script, source

	def notifyError(self, errmsg: str) -> str:
		"""is called when something wants to put out an error message.

		The handlers receive the error message as-is.

		In general, you will be in an exception context when you receive
		this error, but your handlers should not bomb out when you are not.
		"""
		return errmsg

	def notifyFailure(self, flr: Any, preMsg: str = "") -> None:
		"""is called when an unexpected twisted failure is being processed.

		Call this *exclusively* from errbacks and never while handling
		an exception, or you'll get two tracebacks in the logs.

		You should not listen on this, since the handler just receives None.
		Rather, these events are converted to ErrorOccurreds including the
		failure's traceback.
		"""
		try:
			self.notifyError(preMsg
				+"\n"
				+str(flr.value))
		except:  # botched str() of exception.  Oh my.
			self.notifyError("Swallowing unstringable exception")
		# If we're not handling an exception (which is generally true
		# when we're here because failures typically come up in errbacks),
		# provide a trace to info, because other handlers can't see it.
		if sys.exc_info()==(None, None, None):
			self.notifyInfo("Traceback (nf) a request:\n %s\n%s\n"%(
				flr.getErrorMessage(), flr.getTraceback()))

	def notifyWarning(self, message: str) -> str:
		"""is called when something tries to emit communicate non-fatal trouble.

		The handlers receive the message as-is
		"""
		return message

	def notifyInfo(self, message: str) -> str:
		"""is called when something tries to emit auxiliary information.

		The handlers receive the message as-is
		"""
		return message

	def notifyDebug(self, message: str) -> str:
		"""is called when something wants to communicate information only
		useful when trying to figure out a malfunction.

		The handlers receive the message as-is.
		"""
		return message

	def notifyWebServerUp(self) -> None:
		"""is called when the webserver is up and running.

		This passes just a None.
		"""
		return None
	
	def notifyDBTableModified(self, fqName: str) -> str:
		"""is called when an existing database table has been modified.

		The argument is the fully qualified table name.
		"""
		return fqName

	def notifyProcessStarts(self, procDesc: Structure) -> Structure:
		"""is called when a potentially long-running process presumably
		watched by the user starts.

		There can only be one of those at any time per dachs instance.
		You have to make sure that notifyProcessEnded is being called.
		"""
		if self.longRunningProcess is not None:
			# make this an error although it's a UI thing; if this happens,
			# it's pretty clear that someone hasn't cleaned up after
			# themselves, and they need to fix that.
			raise utils.ReportableError(
				f"A process was already active ({self.longRunningProcess}).")

		self.longRunningProcess = procDesc
		return procDesc
	
	def notifyProcessEnded(self) -> Optional[Structure]:
		"""is called when a potentially long-running process has ended.

		The event handler receives the name of the long running process.
		"""
		try:
			return self.longRunningProcess
		finally:
			self.longRunningProcess = None

	def notifyProgress(self, progress: str) -> str:
		"""is called to update a progress indicator.

		Progress is a message (like "56%" or so) that is being handed through
		to the handler.
		"""
		if not self.longRunningProcess:
			raise utils.ReportableError("Progress report without process")
		return progress
