"""
An interface to querying TAP servers (i.e., a TAP client).

This is deprecated.  Use pyvo.dal.tap instead.
"""

#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 datetime
import io
import http.client
import socket
import time
import traceback
import urllib.request, urllib.parse, urllib.error
import urllib.parse
from email.message import Message
from email.mime.multipart import MIMEMultipart
from xml import sax

from gavo import utils
from gavo.votable import votparse
from gavo.votable.model import VOTable as V

from gavo.utils.dachstypes import (cast,
	Any, BytesReadable, Callable, Dict, List, Mapping, Optional, Protocol, Set,
	StrOrBytes, StrToStrMap, Tuple, Type, Union)


# Ward against typos
PENDING = "PENDING"
QUEUED = "QUEUED"
EXECUTING = "EXECUTING"
COMPLETED = "COMPLETED"
ERROR = "ERROR"
ABORTED = "ABORTED"
UNKNOWN = "UNKNOWN"


# override this as you see fit
USER_AGENT = "Python TAP library http://soft.g-vo.org/subpkgs"


debug = False


class Error(utils.Error):
	"""The base class for all TAP-related exceptions.
	"""


class ProtocolError(Error):
	"""is raised when the remote server violated the local assumptions.
	"""


class WrongStatus(ProtocolError):
	"""is raised when request detects the server returned an invalid
	status.

	These are constructed with the status returned (available as
	foundStatus) data payload of the response (available as payload).
	"""
	def __init__(self,
			msg: str,
			foundStatus: int,
			payload: StrOrBytes,
			hint: Optional[Any] = None) -> None:
		ProtocolError.__init__(self, msg, hint)
		self.args = (msg, foundStatus, payload, hint)
		self.payload, self.foundStatus = payload, foundStatus


class RemoteError(Error):
	"""is raised when the remote size signals an error.

	The content of the remote error document can be retrieved in the
	remoteMessage attribute.
	"""
	def __init__(self, remoteMessage: str):
		self.remoteMessage = remoteMessage
		Error.__init__(self,
			"Remote: "+remoteMessage,
			hint="This means that"
			" something in your query was bad according to the server."
			"  Details may be available in the Exceptions' remoteMessage"
			" attribute")
		self.args = (remoteMessage,)

	def __str__(self) -> str:
		return self.remoteMessage


class RemoteAbort(Error):
	"""is raised by certain check functions when the remote side has aborted
	the job.
	"""
	def __init__(self):
		Error.__init__(self, "Aborted")
		self.args = []
	
	def __str__(self) -> str:
		return "The remote side has aborted the job"


class NetworkError(Error):
	"""is raised when a generic network error happens (can't connect,...)
	"""


class _HTTPResponseWithData(http.client.HTTPResponse):
	"""An augmented HTTP response with a patched-in data attribute.
	"""
	data: bytes = b""


class _FormData(MIMEMultipart):
	"""is a container for multipart/form-data encoded messages.

	This is usually used for file uploads.
	"""
	def __init__(self):
		MIMEMultipart.__init__(self, "form-data")
		self.set_param("boundary", "========== bounda r y 930")
		self.epilogue = ""
	
	def addFile(self, paramName: str, fileName: str, data: bytes) -> None:
		"""attaches the contents of fileName under the http parameter name
		paramName.
		"""
		msg = Message()
		msg.set_type("application/octet-stream")
		msg["Content-Disposition"] = "form-data"
		msg.set_param("name", paramName, "Content-Disposition")
		msg.set_param("filename", fileName, "Content-Disposition")
		msg.set_payload(data)
		self.attach(msg)

	def addParam(self, paramName: str, paramVal: bytes) -> None:
		"""adds a form parameter paramName with the (string) value paramVal
		"""
		msg = Message()
		msg["Content-Disposition"] = "form-data"
		msg.set_param("name", paramName, "Content-Disposition")
		msg.set_payload(paramVal)
		self.attach(msg)

	def forHTTPUpload(self) -> str:
		"""returns a string serialisation of this message suitable for HTTP
		upload.

		This is as_string, except we're cutting off the header (which is
		part of the HTTP header for us), and we are changing the line separator
		to crlf.
		"""
		data = self.as_string().split("\n\n", 1)[-1]
		data = data.replace("\n", "\r\n")
		return data

	@classmethod
	def fromDict(cls, dict: Mapping[str, StrOrBytes]) -> "_FormData":
		self = cls()
		for key, value in dict.items():
			self.addParam(key, utils.bytify(value))
		return self
		

def _getErrorInfo(votString: StrOrBytes) -> str:
	"""returns the message from a TAP error VOTable.

	if votString is not a TAP error VOTable, it is returned verbatim
	(decoded as UTF-8)

	TODO: For large responses, this may take a while.  It's probably
	not worth it in such cases.  Or at all.  Maybe we should  hunt
	for the INFO somewhere else?
	"""
	try:
		for el in votparse.parseBytes(utils.bytify(votString), watchset={V.INFO}):
			if isinstance(el, V.INFO):
				if el.name=="QUERY_STATUS" and el.value=="ERROR":
					return el.text_
			else:
				# it's data, which we want to skip quickly
				for _ in el: pass
	except Exception:
		# votString's not a suitable VOTable, fall through to return votString
		pass
	return utils.debytify(votString)


class _JobresParser(Protocol):
	"""an object that takes one of our request results and builds python
	values from its payload.

	In practical terms, this is either a _FlatParser (for which all this
	is a bit too much effort) or a StartEndParser if we have to deal
	with XML responses.
	"""
	def getResult(self) -> Any:
		pass
	
	def parseString(self, data: StrOrBytes) -> Any:
		# the return value is ignored here
		pass


class _FlatParser:
	"""a "parser" for use within _parseWith.
	"""
	def __init__(self, parseFunc: Callable[[StrOrBytes], Any]) -> None:
		self.parseFunc = parseFunc

	def parseString(self, data: StrOrBytes) -> None:
		self.result = self.parseFunc(data)

	def getResult(self):
		return self.result


def _parseWith(parser: "_JobresParser", data: StrOrBytes):
	"""uses the utils.StartEndParser-compatible parser to parse the string data.
	"""
	try:
		parser.parseString(data)
		return parser.getResult()
	except (ValueError, IndexError, sax.SAXParseException):
		if debug:
			traceback.print_exc()
			with open("server_response", "wb") as f:
				f.write(utils.bytify(data))
		raise ProtocolError("Malformed response document.", hint=
			"If debug was enabled, you will find the server response in"
			" the file server_response.")


class _PhaseParser(utils.StartEndHandler):
	"""A parser accepting both plain text and XML replies.

	Of course, the XML replies are against the standard, but "be lenient
	in what you accept", and I used to be out there.
	"""
	result: Optional[str] = ""

	def _end_phase(self, name: str, attrs: StrToStrMap, content: str) -> None:
		self.result = content
	
	def parseString(self, data: StrOrBytes) -> "_PhaseParser":
		if utils.bytify(data).strip().startswith(b"<"): # XML :-)
			utils.StartEndHandler.parseString(self, data)
		else:
			self.result = utils.debytify(data.strip())
		return self
	
	def getResult(self) -> Optional[str]:
		return self.result


class _QuoteParser(utils.StartEndHandler):
	quote: Optional[datetime.datetime] = None

	def parseDate(self, arg: StrOrBytes) -> Optional[datetime.datetime]:
		literal = utils.debytify(arg)
		val = None
		if literal and literal!="NULL":
			val = utils.parseISODT(literal)
		return val

	def _end_quote(self, name: str, attrs: StrToStrMap, content: str) -> None:
		self.quote = self.parseDate(content.strip())

	def parseString(self, data: StrOrBytes) -> utils.StartEndHandler:
		data = data.strip()
		if utils.bytify(data).startswith(b"<"): # XML :-)
			utils.StartEndHandler.parseString(self, data)
		else:
			self.quote = self.parseDate(data)
		return self

	def getResult(self) -> Optional[datetime.datetime]:
		return self.quote


class _CaselessDictionary(dict):
	"""A dictionary that only has lower-case keys but treats keys in any
	capitalization as equivalent.
	"""
	def __contains__(self, key: object) -> bool:
		if isinstance(key, str):
			key = key.lower()
		return dict.__contains__(self, key)
	
	def __getitem__(self, key: str) -> bool:
		return dict.__getitem__(self, key.lower())
	
	def __setitem__(self, key: str, value: Any) -> None:
		dict.__setitem__(self, key.lower(), value)
	
	def __delitem__(self, key: str) -> None:
		dict.__delitem__(self, key.lower())
	

class _ParametersParser(utils.StartEndHandler):
	def _initialize(self) -> None:
		self.parameters = _CaselessDictionary()

	def _end_parameter(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		self.parameters[attrs["id"]] = content
	
	def getResult(self) -> _CaselessDictionary:
		return self.parameters


class _ResultsParser(utils.StartEndHandler):
	def _initialize(self) -> None:
		self.results: List[UWSResult] = []

	def _end_result(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		attrs = self.getAttrsAsDict(attrs)
		self.results.append(UWSResult(attrs["href"],
			attrs.get("id"), attrs.get("type", "simple")))
	
	def getResult(self) -> List["UWSResult"]:
		return self.results


class _InfoParser(_ParametersParser, _ResultsParser):
	def _initialize(self) -> None:
		self.info: Dict[str, Any] = {}
		_ParametersParser._initialize(self)
		_ResultsParser._initialize(self)
	
	def _end_jobId(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		self.info[name] = content
	
	_end_phase = _end_jobId
	
	def _end_executionDuration(self,
			name: str, attrs: StrToStrMap, content: str) -> None:
		self.info[name] = float(content)
	
	def _end_destruction(self,
			name: str, attrs: StrToStrMap, content: str) -> None:
		self.info[name] = utils.parseISODT(content)
	
	def _end_job(self,
			name: str, attrs: StrToStrMap, content: str) -> None:
		self.info["results"] = self.results
		self.info["parameters"] = self.parameters
	
	def getResult(self) -> Dict[str, Any]: # type: ignore # inheritance is a bit wonky here
		return self.info


class _AvailabilityParser(utils.StartEndHandler):
# Let's drop this; availability was a bad idea.
	available: Optional[bool] = None

	def _end_available(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		content = content.strip()
		if content=="true":
			self.available = True
		elif content=="false":
			self.available = False

	def getResult(self) -> Optional[bool]:
		return self.available


def _pruneAttrNS(attrs: StrToStrMap) -> StrToStrMap:
	return dict((k.split(":")[-1], v) for k,v in list(attrs.items()))


class _CapabilitiesParser(utils.StartEndHandler):
# VOSI; each capability is a dict with at least a key interfaces.
# each interface is a dict with key type (namespace prefix not expanded;
# change that?), accessURL, and use.
	def __init__(self) -> None:
		utils.StartEndHandler.__init__(self)
		self.capabilities: List[Dict[str, Any]] = []
		self.curInterface: Optional[Dict[str, Any]] = None

	def _start_capability(self, name: str, attrs: StrToStrMap) -> None:
		self.curCap: Optional[Dict[str, Any]] = {"interfaces": []}
		self.curCap["standardID"] = attrs.get("standardID")

	def _end_capability(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		if self.curCap is not None:
			self.capabilities.append(self.curCap)
		self.curCap = None

	def _start_interface(self, name: str, attrs: StrToStrMap):
		attrs = _pruneAttrNS(attrs)
		self.curInterface = {
			"type": attrs["type"],
			"role": attrs.get("role")}

	def _end_interface(self,name: str, attrs: StrToStrMap, content: str
			) -> None:
		if self.curInterface is not None and self.curCap is not None:
			self.curCap["interfaces"].append(self.curInterface)
		self.curInterface = None

	def _end_accessURL(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		if self.curInterface is not None:
			self.curInterface["accessURL"] = content.strip()
			self.curInterface["use"] = attrs.get("use")
	
	def getResult(self) -> List[Dict[str, Any]]:
		return self.capabilities


class _TablesParser(utils.StartEndHandler):
	def __init__(self) -> None:
		utils.StartEndHandler.__init__(self)
		self.tables: List[V.TABLE] = []
		self.curCol: Optional[V.FIELD] = None
	
	def _start_table(self, name: str, attrs: StrToStrMap) -> None:
		self.tables.append(V.TABLE())
	
	def _start_column(self, name: str, attrs: StrToStrMap) -> None:
		self.curCol = V.FIELD()

	def _end_column(self, name: str, attrs: StrToStrMap, content: str
			) -> None:
		self.tables[-1][self.curCol]
		self.curCol = None

	def _end_description(self,
			attname: str, attrs: StrToStrMap, content: str) -> None:
		if self.getParentTag()=="table":
			destObj: V._VOTElement = self.tables[-1]
		elif self.getParentTag()=="column" and self.curCol is not None:
			destObj = self.curCol
		else:
			# name/desc of something else -- ignore
			return
		destObj[V.DESCRIPTION[content]]

	def _endColOrTableAttr(self,
			attname: str, attrs: StrToStrMap, content: str) -> None:
		if self.getParentTag()=="table":
			destObj: V._VOTElement = self.tables[-1]
		elif self.getParentTag()=="column" and self.curCol is not None:
			destObj = self.curCol
		else:
			# name/desc of something else -- ignore
			return
		destObj(**{str(attname): content.strip()})
	
	_end_name = _endColOrTableAttr
	
	def _endColAttr(self, attName: str, attrs: StrToStrMap, content: str
			) -> None:
		if self.curCol is not None:
			self.curCol(**{str(attName): content.strip()})

	_end_unit = _end_ucd = _endColAttr
	
	def _end_dataType(self, attname: str, attrs: StrToStrMap, content: str
			) -> None:
		if self.curCol is not None:
			self.curCol(datatype=content.strip())
			if "arraysize" in attrs:
				self.curCol(arraysize=attrs["arraysize"])

	def getResult(self) -> List[V.TABLE]:
		return self.tables


class UWSResult:
	"""a container type for a result returned by an UWS service.

	It exposes id, href, and type attributes.
	"""
	def __init__(self,
			href: str,
			id: Optional[str] = None,
			type: Optional[str] = None) -> None:
		self.href, self.id, self.type = href, id, type


class LocalResult:
	def __init__(self, data: bytes, id: str, type: str) -> None:
		self.data, self.id, self.type = data, id, type


def _canUseFormEncoding(params: Mapping[str, Any]) -> bool:
	"""returns true if userParams can be transmitted in a
	x-www-form-urlencoded payload.
	"""
	for val in list(params.values()):
		if not isinstance(val, str):
			return False
	return True

ACCEPTED_REDIRECTS = {301, 302, 303, 307}


def request(
		scheme: StrOrBytes,
		host: StrOrBytes,
		path: StrOrBytes,
		data: Mapping[str, StrOrBytes] | str = "",
		customHeaders: StrToStrMap = {},
		method: str = "GET",
		expectedStatus: Optional[int] = None,
		followRedirects: int = 0,
		setResponse: Optional[Callable[[bytes], None]] = None,
		timeout: Optional[float] = None) -> _HTTPResponseWithData:
	"""returns a HTTPResponse object for an HTTP request to path on host.

	This function builds a new connection for every request.

	On the returned object, you cannot use the read() method.	Instead
	any data returned by the server is available in the data attribute.

	data usually is a byte string, but you can also pass a dictionary
	which then will be serialized using _FormData above.

	You can set followRedirects to a positive integer.  This means that the
	303 "See other" codes that many UWS action generate will be followed
	and the document at the other end will be obtained.  DaCHS will
	follow as many redirects.

	In setResponse, you can pass in a callable that is called with the
	server response body as soon as it is in.  This is for when you want
	to store the response even if request raises an error later on
	(i.e., for sync querying).
	"""
	if scheme=="http":
		connClass: Union[
			Type[http.client.HTTPConnection],
			Type[http.client.HTTPSConnection]]= http.client.HTTPConnection
	elif scheme=="https":
		connClass = http.client.HTTPSConnection
	else:
		assert False

	headers = {"connection": "close",
		"user-agent": USER_AGENT}

	if not isinstance(data, str):
		if _canUseFormEncoding(data):
			data = urllib.parse.urlencode(data)
			headers["Content-Type"] = "application/x-www-form-urlencoded"

		else:
			form = _FormData.fromDict(data)
			data = form.forHTTPUpload()
			headers["Content-Type"] = form.get_content_type()+'; boundary="%s"'%(
					form.get_boundary())

	headers["Content-Length"] = str(len(data))
	headers.update(customHeaders)

	try:
		conn = connClass(utils.debytify(host), timeout=timeout)
		conn.request(utils.debytify(method), utils.debytify(path), data, headers)
	except (socket.error, http.client.error) as ex:
		raise NetworkError("Problem connecting to {} ({})".format(
			utils.debytify(host), ex))

	resp: _HTTPResponseWithData = cast(_HTTPResponseWithData, conn.getresponse())
	resp.data = resp.read()
	if setResponse is not None:
		setResponse(resp.data)
	conn.close()

	if followRedirects and resp.status in ACCEPTED_REDIRECTS:
		parts = urllib.parse.urlparse(resp.getheader("location"))
		return request(parts.scheme, parts.netloc, parts.path,
			method="GET", expectedStatus=expectedStatus,
			followRedirects=followRedirects-1)

	if expectedStatus is not None:
		if resp.status!=expectedStatus:
			raise WrongStatus("Expected status %s, got status %s"%(
				expectedStatus, resp.status), resp.status, resp.data)
	return resp


def _makeAtomicValueGetter(methodPath: str, parser: _FlatParser
		) -> Callable[[_FlatParser], Any]:
# This is for building ADQLTAPJob's properties (phase, etc.)
	def getter(self):
		destURL = self.jobPath+methodPath
		response = request(self.destScheme, self.destHost, destURL,
			expectedStatus=200)
		return _parseWith(parser, response.data)
	return getter


def _makeAtomicValueSetter(
		methodPath: str,
		serializer: Callable[[Any], str],
		parameterName: str) -> Callable[["ADQLTAPJob", Any], None]:
# This is for building ADQLTAPJob's properties (phase, etc.)
	def setter(self: "ADQLTAPJob", value: Any) -> None:
		destURL = self.jobPath+methodPath
		request(self.destScheme, self.destHost, destURL,
			{parameterName: serializer(value)}, method="POST",
			expectedStatus=303)
	return setter


class _WithEndpoint:
	"""A helper class for classes constructed with an ADQL endpoint.
	"""
	def _defineEndpoint(self, endpointURL: str) -> None:
		self.endpointURL = endpointURL.rstrip("/")
		parts = urllib.parse.urlsplit(self.endpointURL)
		self.destScheme = parts.scheme
		self.destHost = cast(str, parts.hostname)
		if parts.port:
			self.destHost = "%s:%s"%(self.destHost, parts.port)
		self.destPath = parts.path
		if self.destPath.endswith("/"):
			self.destPath = self.destPath[:-1]



class ADQLTAPJob(_WithEndpoint):
	"""A facade for an ADQL-based async TAP job.

	Construct it with the URL of the async endpoint and a query.

	Alternatively, you can give the endpoint URL and a jobId as a
	keyword parameter.  This only makes sense if the service has
	handed out the jobId before (e.g., when a different program takes
	up handling of a job started before).

	See :dachsdoc:`adql.html` for details.
	"""
	def __init__(self,
			endpointURL: str,
			query: Optional[str] = None,
			jobId: Optional[str] = None,
			lang: str = "ADQL",
			userParams: StrToStrMap = {},
			timeout: Optional[int] = None) -> None:
		self._defineEndpoint(endpointURL)
		self.timeout = timeout
		self.destPath = utils.ensureOneSlash(self.destPath)+"async"
		if query is not None:
			self.jobId: Optional[str] = None
			self.jobPath: str = ""
			self._createJob(query, lang, userParams)
		elif jobId is not None:
			self.jobId = jobId
		else:
			raise Error("Must construct ADQLTAPJob with at least query or jobId")
		self._computeJobPath()
	
	def _computeJobPath(self) -> None:
		self.jobPath = "%s/%s"%(self.destPath, self.jobId)

	def _createJob(self, query: str, lang: str, userParams: StrToStrMap
			) -> None:
		params = {
			"REQUEST": "doQuery",
			"LANG": lang,
			"QUERY": query}
		for k,v in userParams.items():
			params[k] = str(v)
		response = request(self.destScheme, self.destHost, self.destPath, params,
			method="POST", expectedStatus=303, timeout=self.timeout)
		# The last part of headers[location] now contains the job id
		try:
			self.jobId = urllib.parse.urlsplit(
				response.getheader("location", "")).path.split("/")[-1]
		except ValueError:
			raise utils.logOldExc(
				ProtocolError("Job creation returned invalid job id"))

	def delete(self, usePOST: bool = False) -> None:
		"""removes the job on the remote side.

		usePOST=True can be used for servers that do not support the DELETE
		HTTP method (a.k.a. "are broken").
		"""
		if self.jobPath is not None:
			if usePOST:
				request(self.destScheme, self.destHost, self.jobPath, method="POST",
					data={"ACTION": "DELETE"}, expectedStatus=303,
					timeout=self.timeout)
			else:
				request(self.destScheme, self.destHost, self.jobPath, method="DELETE",
					expectedStatus=303, timeout=self.timeout)

	def start(self) -> None:
		"""asks the remote side to start the job.
		"""
		request(self.destScheme, self.destHost, self.jobPath+"/phase",
			{"PHASE": "RUN"}, method="POST", expectedStatus=303,
			timeout=self.timeout)

	def abort(self) -> None:
		"""asks the remote side to abort the job.
		"""
		request(self.destScheme, self.destHost, self.jobPath+"/phase",
			{"PHASE": "ABORT"}, method="POST", expectedStatus=303,
			timeout=self.timeout)

	def raiseIfError(self) -> None:
		"""raises an appropriate error message if job has thrown an error or
		has been aborted.
		"""
		phase = self.phase
		if phase==ERROR:
			raise RemoteError(self.getErrorFromServer())
		elif phase==ABORTED:
			raise RemoteAbort()

	def waitForPhases(self,
			phases: Set[str],
			pollInterval: float = 1,
			increment: float = 1.189207115002721,
			giveUpAfter: Optional[int] = None) -> None:
		"""waits for the job's phase to become one of the set phases.

		This method polls.  Initially, it does increases poll times
		exponentially with increment until it queries every two minutes.

		The magic number in increment is 2**(1/4.).

		giveUpAfter, if given, is the number of iterations this method will
		do.  If none of the desired phases have been found until then,
		raise a ProtocolError.
		"""
		attempts = 0
		while True:
			curPhase = self.phase
			if curPhase in phases:
				break
			time.sleep(pollInterval)
			pollInterval = min(120, pollInterval*increment)
			attempts += 1
			if giveUpAfter:
				if attempts>giveUpAfter:
					raise ProtocolError("None of the states in %s were reached"
						" in time."%repr(phases),
					hint="After %d attempts, phase was %s"%(attempts, curPhase))

	def run(self, pollInterval: float = 1) -> None:
		"""runs the job and waits until it has finished.

		The function raises an exception with an error message gleaned from the
		server.
		"""
		self.start()
		self.waitForPhases(set([COMPLETED, ABORTED, ERROR]))
		self.raiseIfError()

	executionDuration = property(
		_makeAtomicValueGetter("/executionduration", _FlatParser(float)),
		_makeAtomicValueSetter("/executionduration", str, "EXECUTIONDURATION"))

	destruction = property(
		_makeAtomicValueGetter("/destruction", _FlatParser(
			lambda data: utils.parseISODT(utils.debytify(data)))),
		_makeAtomicValueSetter("/destruction",
			lambda dt: dt.strftime("%Y-%m-%dT%H:%M:%S.000"), "DESTRUCTION"))

	def makeJobURL(self, jobPath: str) -> str:
		return self.endpointURL+"/async/%s%s"%(self.jobId, jobPath)

	def _queryJobResource(self, path: str, parser: _JobresParser) -> Any:
		# a helper for phase, quote, etc.
		response = request(self.destScheme, self.destHost, self.jobPath+path,
			expectedStatus=200, timeout=self.timeout)
		return _parseWith(parser, response.data)

	@property
	def info(self) -> Dict[str, Any]:
		"""returns a dictionary of much job-related information.
		"""
		return self._queryJobResource("", _InfoParser())

	@property
	def phase(self) -> str:
		"""returns the phase the job is in according to the server.
		"""
		return self._queryJobResource("/phase", _PhaseParser())

	@property
	def quote(self) -> datetime.datetime:
		"""returns the estimate the server gives for the run time of the job.
		"""
		return self._queryJobResource("/quote", _QuoteParser())

	@property
	def owner(self) -> str:
		"""returns the owner of the job.
		"""
		return self._queryJobResource("/owner",
			_FlatParser(lambda data: utils.debytify(data)))

	@property
	def parameters(self) -> _CaselessDictionary:
		"""returns a dictionary mapping passed parameters to server-provided
		string representations.

		To set a parameter, use the setParameter function.  Changing the
		dictionary returned here will have no effect.
		"""
		return self._queryJobResource("/parameters", _ParametersParser())

	@property
	def allResults(self) -> List[UWSResult]:
		"""returns a list of UWSResult instances.
		"""
		return self._queryJobResource("/results", _ResultsParser())

	def getResultURL(self, simple: bool = True) -> str:
		"""returns the URL of the ADQL result table.
		"""
		if simple:
			return self.makeJobURL("/results/result")
		else:
			return self.allResults[0].href

	def openResult(self, simple: bool = True
			) -> str:
		"""returns a file-like object you can read the default TAP result off.

		To have the embedded VOTable returned, say
		votable.load(job.openResult()).

		If you pass simple=False, the URL will be taken from the
		service's result list (the first one given there).  Otherwise (the
		default), results/result is used.
		"""
		return urllib.request.urlopen(self.getResultURL())

	def setParameter(self, key: str, value: str) -> None:
		request(self.destScheme, self.destHost, self.jobPath+"/parameters",
			data={key: value}, method="POST", expectedStatus=303,
			timeout=self.timeout)

	def getErrorFromServer(self) -> str:
		"""returns the error message the server gives, verbatim.
		"""
		data = request(self.destScheme, self.destHost, self.jobPath+"/error",
			expectedStatus=200, followRedirects=True,
			timeout=self.timeout).data
		return _getErrorInfo(data)

	def addUpload(self, name: str, data: bytes | BytesReadable) -> None:
		"""adds uploaded tables, either from a file or as a remote URL.

		You should not try to change UPLOAD yourself (e.g., using setParameter).

		Data is either a string (i.e. a URI) or a file-like object (an upload).
		"""
		uploadFragments = []
		form = _FormData()
		if isinstance(data, str): # a URI
			assert ',' not in data
			assert ';' not in data
			uploadFragments.append("%s,%s"%(name, data))

		else: # Inline upload, data is a file
			uploadKey = utils.intToFunnyWord(id(data))
			form.addFile(uploadKey, uploadKey, cast(BytesReadable, data).read())
			uploadFragments.append("%s,param:%s"%(name, uploadKey))

		form.addParam("UPLOAD", utils.bytify(";".join(uploadFragments)))
		request(self.destScheme, self.destHost, self.jobPath+"/parameters",
			method="POST",
			data=form.forHTTPUpload(), expectedStatus=303,
			customHeaders={"content-type":
				form.get_content_type()+'; boundary="%s"'%(form.get_boundary())})


class ADQLSyncJob(_WithEndpoint):
	"""A facade for a synchronous TAP Job.

	This really is just a very glorified urllib.urlopen.  Maybe some
	superficial parallels to ADQLTAPJob are useful.

	You can construct it, add uploads, and then start or run the thing.
	Methods that make no sense at all for sync jobs ("phase") silently
	return some more or less sensible fakes.
	"""
	def __init__(self,
			endpointURL: str,
			query: Optional[str] = None,
			jobId: Optional[str] = None,
			lang: str = "ADQL",
			userParams: Dict[str, str] = {},
			timeout: Optional[float] = None) -> None:
		self._defineEndpoint(endpointURL)
		self.query, self.lang = query, lang
		self.userParams = userParams.copy()
		self.result: Optional[LocalResult] = None
		self.uploads: List[Tuple[str, str, bytes]] = []
		self._errorFromServer: Optional[str] = None
		self.timeout = timeout
	
	def postToService(self, params: Dict[str, StrOrBytes]
			) -> _HTTPResponseWithData:
		return request(self.destScheme, self.destHost, self.destPath+"/sync",
			params,
			method="POST", followRedirects=5, expectedStatus=200,
			setResponse=self._setErrorFromServer, timeout=self.timeout)

	def delete(self, usePOST: bool = False) -> None:
		# Nothing to delete
		pass
	
	def abort(self) -> None:
		"""does nothing.

		You could argue that this could come from a different thread and we
		could try to interrupt the ongoing request.  Well, if you want it,
		try it yourself or ask the author.
		"""

	def raiseIfError(self) -> None:
		if self._errorFromServer is not None:
			raise Error(self._errorFromServer)

	def waitForPhases(self,
			phases: Set[str],
			pollInterval: float = 1,
			increment: float = 1.189207115002721,
			giveUpAfter: Optional[int] = None) -> None:
		# you could argue that sync jobs are in no phase, but I'd say
		# they are in all of them at the same time:
		return

	def _setErrorFromServer(self, data: StrOrBytes) -> None:
		# this is a somewhat convolved way to get server error messages
		# out of request even when it later errors out.  See the
		# except construct around the postToService call in start()
		#
		# Also, try to interpret what's coming back as a VOTable with an
		# error message; _getErrorInfo is robust against other junk.
		self._errorFromServer = _getErrorInfo(data)

	def start(self) -> "ADQLSyncJob":
		if self.query is None:
			raise NotImplementedError("No query?")

		params: Dict[str, StrOrBytes] = {
			"REQUEST": "doQuery",
			"LANG": self.lang,
			"QUERY": self.query}
		params.update(self.userParams)
		if self.uploads:
			upFrags = []
			for name, key, data in self.uploads:
				upFrags.append("%s,param:%s"%(name, key))
				params[key] = data
			params["UPLOAD"] = ";".join(upFrags)

		params = dict((k, str(v)) for k,v in params.items())

		try:
			resp = self.postToService(params)
			self.result = LocalResult(
				resp.data, "TAPResult", cast(str, resp.getheader("Content-Type")))
		except Exception as msg:
			# do not clear _errorFromServer; but if it's empty, make up one
			# from our exception
			if not self._errorFromServer:
				self._errorFromServer = str(msg)
			raise
		else:
			# all went well, clear error indicator
			self._errorFromServer = None
		return self

	def run(self, pollInterval: Optional[float] = None):
		return self.start()

	@property
	def info(self) -> StrToStrMap:
		return {}

	@property
	def phase(self) -> None:
		return None
	
	@property
	def quote(self) -> None:
		return None
	
	@property
	def owner(self) -> None:
		return None
	
	@property
	def parameters(self) -> StrToStrMap:
		return self.userParams
	
	@property
	def allResults(self) -> List[LocalResult]:
		if self.result is None:
			return []
		else:
			return [self.result]

	def openResult(self, simple: bool = True) -> io.BytesIO:
		if self.result is None:
			raise Error("No result in so far")
		return io.BytesIO(self.result.data)

	def setParameter(self, key: str, value: Any) -> None:
		self.userParams[key] = value

	def getErrorFromServer(self) -> Optional[str]:
		return self._errorFromServer

	def addUpload(self, name: str, data: bytes) -> None:
		if hasattr(data, "read"):
			data = data.read()
		if not isinstance(data, bytes):
			raise NotImplementedError("Upload source must be file or bytes")
		key = utils.intToFunnyWord(id(data))
		self.uploads.append((name, key, data))


class ADQLEndpoint(_WithEndpoint):
	"""A facade for an ADQL endpoint.

	This is only needed for inspecting server metadata (i.e., in general
	only for rather fancy applications).
	"""
	def __init__(self, endpointURL: str) -> None:
		self._defineEndpoint(endpointURL)
	
	def createJob(self,
			query: str,
			lang: str = "ADQL-2.0",
			userParams: StrToStrMap = {}):
		return ADQLTAPJob(
			self.endpointURL, query, lang=lang, userParams=userParams)

	@property
	def available(self) -> Optional[bool]:
		"""returns True, False, or None (undecidable).

		None is returned when /availability gives a 404 (which is legal)
		or the returned document doesn't parse.
		"""
		try:
			response = request(self.destScheme, self.destHost,
				self.destPath+"/availability", expectedStatus=200)
			res = _parseWith(_AvailabilityParser(), response.data)
		except WrongStatus:
			res = None
		return res
	
	@property
	def capabilities(self) -> List[StrToStrMap]:
		"""returns a dictionary containing some meta info on the remote service.

		Keys to look for include title, identifier, contact (the mail address),
		and referenceURL.

		If the remote server doesn't return capabilities as expected, an
		empty dict is returned.
		"""
		return _parseWith(_CapabilitiesParser(),
				request(self.destScheme, self.destHost,
					self.destPath+"/capabilities").data)

	@property
	def tables(self) -> List[V.TABLE]:
		"""returns a sequence of table definitions for the tables accessible
		through this service.

		The table definitions come as gavo.votable.Table instances.
		"""
		return _parseWith(_TablesParser(),
				request(self.destScheme, self.destHost, self.destPath+"/tables").data)
