#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""
.. _toaccess:

.. program:: toaccess

``toaccess``
============
This module provides a set of functions meant to provide ease-of-use functionality for interacting
with the Traffic Ops API. It provides scripts named :file:`to{method}` where `method` is the name of
an HTTP method (in lowercase). Collectively they are referred to as :program:`toaccess` Implemented
methods thus far are:

- delete
- head
- get
- options
- patch
- post
- put

Arguments and Flags
-------------------
.. option:: PATH

	This is the request path. By default, whatever is passed is considered to be relative to
	:file:`/api/{api-version}/` where ``api-version`` is :option:`--api-version`. This behavior can
	be disabled by using :option:`--raw-path`.

.. option:: DATA

	An optional positional argument that is a data payload to pass to the Traffic Ops server in the
	request body. If this is the absolute or relative path to a file, the contents of the file will
	instead be read and used as the request payload.

.. option:: -h, --help

	Print usage information and exit

.. option:: -a API_VERSION, --api-version API_VERSION

	Specifies the version of the Traffic Ops API that will be used for the request. Has no effect if
	:option:`--raw-path` is used. (Default: 2.0)

.. option:: -f, --full

	Output the full HTTP exchange including request method line, request headers, request body (if
	any), response status line, and response headers (as well as the response body, if any). This is
	equivalent to using :option:`--request-headers`, :option:`--request-payload`, and
	:option:`--response-headers` at the same time, and those options will have no effect if given.
	(Default: false)

.. option:: -k, --insecure

	Do not verify SSL certificates - typically useful for making requests to development or testing
	servers as they frequently have self-signed certificates. (Default: false)

.. option:: -p, --pretty

	Pretty-print any payloads that are output as formatted JSON. Has no effect on plaintext payloads.
	Uses tab characters for indentation. (Default: false)

.. option:: -r, --raw-path

	Request exactly :option:`PATH`; do not preface the request path with :file:`/api/{api_version}`.
	This effectively means that :option:`--api-version` will have no effect. (Default: false)

.. option:: -v, --version

	Print version information and exit.

.. option:: --request-headers

	Output the request method line and any and all request headers. (Default: false)

.. option:: --request-payload

	Output the request body if any was sent. Will attempt to pretty-print the body as JSON if
	:option:`--pretty` is used. (Default: false)

.. option:: --response-headers

	Output the response status line and any and all response headers. (Default: false)

.. option:: --to-url URL

	The :abbr:`FQDN (Fully Qualified Domain Name)` and optionally the port and scheme of the Traffic
	Ops server. This will override :envvar:`TO_URL`. The format is the same as for :envvar:`TO_URL`.
	(Default: uses the value of :envvar:`TO_URL`)

.. option:: --to-password PASSWORD

	The password to use when authenticating to Traffic Ops. Overrides :envvar:`TO_PASSWORD`.
	(Default: uses the value of :envvar:`TO_PASSWORD`)

.. option:: --to-user USERNAME

	The username to use when connecting to Traffic Ops. Overrides :envvar:`TO_USER`. (Default: uses
	the value of :envvar:`TO_USER`)

Environment Variables
---------------------
If defined, :program:`toaccess` scripts will use the :envvar:`TO_URL`, :envvar:`TO_USER`, and
:envvar`TO_PASSWORD` environment variables to define their connection to and authentication with the
Traffic Ops server. Typically, setting these is easier than using the long options :option:`--to-url`,
:option:`--to-user`, and :option:`--to-password` on every invocation.

Exit Codes
----------
The exit code of a :program:`toaccess` script can sometimes be used by the caller to determine what
the result of calling the script was without needing to parse the output. The exit codes used are:

0
	The command executed successfully, and the result is on STDOUT.
1
	Typically this exit code means that an error was encountered when parsing positional command
	line arguments. However, this is also the exit code used by most Python interpreters to signal
	an unhandled exception.
2
	Signifies a runtime error that caused the request to fail - this is **not** generally indicative
	of an HTTP client or server error, but rather an underlying issue connecting to or
	authenticating with Traffic Ops. This is distinct from an exit code of ``32`` in that the
	*format* of the arguments was correct, but there was some problem with the *value*. For example,
	passing ``https://test:`` to :option:`--to-url` will cause an exit code of ``2``, not ``32``.
4
	An HTTP client error occurred. The HTTP stack will be printed to stdout as indicated by other
	options - meaning by default it will only print the response payload if one was given, but will
	respect options like e.g. :option:`--request-payload` as well as
	:option:`-p`/:option:`--pretty`.
5
	An HTTP server error occurred. The HTTP stack will be printed to stdout as indicated by other
	options - meaning by default it will only print the response payload if one was given, but will
	respect options like e.g. :option:`--request-payload` as well as
	:option:`-p`/:option:`--pretty`.
32
	This is the error code emitted by Python's :mod:`argparse` module when the passed arguments
	could not be parsed successfully.

.. note:: The way exit codes ``4`` and ``5`` are implemented is by returning the status code of the
	HTTP request divided by 100 whenever it is at least 400. This means that if the Traffic Ops
	server ever started returning e.g. 700 status codes, the exit code of the script would be 7.


Module Reference
================

"""
import json
import logging
import os
import sys
from urllib.parse import urlparse

from trafficops.restapi import LoginError, OperationError, InvalidJSONError
from trafficops.tosession import TOSession
from trafficops.__version__ import __version__

from requests.exceptions import RequestException

l = logging.getLogger()
l.disabled = True
logging.basicConfig(level=logging.CRITICAL+1)

def output(r, pretty, request_header, response_header, request_payload, indent = '\t'):
	"""
	Prints the passed response object in a format consistent with the other parameters.

	:param r: The :mod:`requests` response object being printed
	:param pretty: If :const:`True`, attempt to pretty-print payloads as JSON
	:param request_header: If :const:`True`, print request line and request headers
	:param response_header: If :const:`True`, print response line and response headers
	:param request_payload: If :const:`True`, print the request payload
	:param indent: An optional number of spaces for pretty-printing indentation (default is the tab character)
	"""
	if request_header:
		print(r.request.method, r.request.path_url, "HTTP/1.1")
		for h,v in r.request.headers.items():
			print("%s:" % h, v)
		print()

	if request_payload and r.request.body:
		try:
			result = r.request.body if not pretty else json.dumps(json.loads(r.request.body))
		except ValueError:
			result = r.request.body
		print(result, end="\n\n")

	if response_header:
		print("HTTP/1.1", r.status_code, end="")
		print(" "+r.reason if r.reason else "")
		for h,v in r.headers.items():
			print("%s:" % h, v)
		print()

	try:
		result = r.text if not pretty else json.dumps(r.json(), indent=indent)
	except ValueError:
		result = r.text
	print(result)

def parse_arguments(program):
	"""
	A common-use function that parses the command line arguments.

	:param program: The name of the program being run - used for usage informational output
	:returns: The Traffic Ops HTTP session object, the requested path, any data to be sent, an output
	          format specification, whether or not the path is raw, and whether or not output should
	          be prettified
	"""
	from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
	parser = ArgumentParser(prog=program,
	                        formatter_class=ArgumentDefaultsHelpFormatter,
	                        description="A helper program for interfacing with the Traffic Ops API",
	                        epilog=("Typically, one will want to connect and authenticate by defining "
	                               "the 'TO_URL', 'TO_USER' and 'TO_PASSWORD' environment variables "
	                               "rather than (respectively) the '--to-url', '--to-user' and "
	                               "'--to-password' command-line flags. Those flags are only "
	                               "required when said environment variables are not defined.\n"
	                               "%(prog)s will exit with a success provided a response was "
	                               "received and the status code of said response was less than 400. "
	                               "The exit code will be 1 if command line arguments cannot be "
	                               "parsed or authentication with the Traffic Ops server fails. "
	                               "In the event of some unknown error occurring when waiting for a "
	                               "response, the exit code will be 2. If the server responds with "
	                               "a status code indicating a client or server error, that status "
	                               "code will be used as the exit code."))

	parser.add_argument("--to-url",
	                    type=str,
	                    help=("The fully qualified domain name of the Traffic Ops server. Overrides "
	                         "'$TO_URL'. The format for both the environment variable and the flag "
	                         "is '[scheme]hostname[:port]'. That is, ports should be specified here, "
	                         "and they need not start with 'http://' or 'https://'. HTTPS is the "
	                         "assumed protocol unless the scheme _is_ provided and is 'http://'."))
	parser.add_argument("--to-user",
	                    type=str,
	                    help="The username to use when connecting to Traffic Ops. Overrides '$TO_USER")
	parser.add_argument("--to-password",
	                    type=str,
	                    help="The password to use when authenticating to Traffic Ops. Overrides '$TO_PASSWORD'")
	parser.add_argument("-k", "--insecure", action="store_true", help="Do not verify SSL certificates")
	parser.add_argument("-f", "--full",
	                    action="store_true",
	                    help=("Also output HTTP request/response lines and headers, and request payload. "
	                         "This is equivalent to using '--request-headers', '--response-headers' "
	                         "and '--request-payload' at the same time."))
	parser.add_argument("--request-headers",
	                    action="store_true",
	                    help="Output request method line and headers")
	parser.add_argument("--response-headers",
	                    action="store_true",
	                    help="Output response status line and headers")
	parser.add_argument("--request-payload",
	                    action="store_true",
	                    help="Output request payload (will try to pretty-print if '--pretty' is given)")
	parser.add_argument("-r", "--raw-path",
	                    action="store_true",
	                    help="Request exactly PATH; it won't be prefaced with '/api/{{api-version}}/")
	parser.add_argument("-a", "--api-version",
	                    type=float,
	                    default=3.0,
	                    help="Specify the API version to request against")
	parser.add_argument("-p", "--pretty",
	                    action="store_true",
	                    help=("Pretty-print payloads as JSON. "
	                         "Note that this will make Content-Type headers \"wrong\", in general"))
	parser.add_argument("-v", "--version",
	                    action="version",
	                    help="Print version information and exit",
	                    version="%(prog)s v"+__version__)
	parser.add_argument("PATH", help="The path to the resource being requested - omit '/api/2.x'")
	parser.add_argument("DATA",
	                    help=("An optional data string to pass with the request. If this is a "
	                         "filename, the contents of the file will be sent instead."),
	                    nargs='?')


	args = parser.parse_args()

	try:
		to_host = args.to_url if args.to_url else os.environ["TO_URL"]
	except KeyError as e:
		raise KeyError("Traffic Ops hostname not set! Set the TO_URL environment variable or use "\
		               "'--to-url'.") from e

	original_to_host = to_host
	to_host = urlparse(to_host, scheme="https")
	useSSL = to_host.scheme.lower() == "https"
	to_port = to_host.port
	if to_port is None:
		if useSSL:
			to_port = 443
		else:
			to_port = 80

	to_host = to_host.hostname
	if not to_host:
		raise KeyError(f"Invalid URL/host for Traffic Ops: '{original_to_host}'")

	s = TOSession(to_host,
	              host_port=to_port,
	              ssl=useSSL,
	              api_version=f"{args.api_version:.1f}",
	              verify_cert=not args.insecure)

	data = args.DATA
	if data and os.path.isfile(data):
		with open(data) as f:
			data = f.read()

	if isinstance(data, str):
		data = data.encode()

	try:
		to_user = args.to_user if args.to_user else os.environ["TO_USER"]
	except KeyError as e:
		raise KeyError("Traffic Ops user not set! Set the TO_USER environment variable or use "\
		               "'--to-user'.") from e

	try:
		to_passwd = args.to_password if args.to_password else os.environ["TO_PASSWORD"]
	except KeyError as e:
		raise KeyError("Traffic Ops password not set! Set the TO_PASSWORD environment variable or "\
		               "use '--to-password'") from e

	# TOSession objects return LoginError when certs are invalid, OperationError when
	# login actually fails
	try:
		s.login(to_user, to_passwd)
	except LoginError as e:
		raise PermissionError(
			"certificate verification failed, the system may have a self-signed certificate - try using -k/--insecure"
		) from e
	except (OperationError, InvalidJSONError) as e:
		raise PermissionError(e) from e
	except RequestException as e:
		raise ConnectionError("Traffic Ops host not found: Name or service not known") from e

	return (s,
	       args.PATH,
	       data,
	       (
	         args.request_headers or args.full,
	         args.response_headers or args.full,
	         args.request_payload or args.full
	       ),
	       args.raw_path,
	       args.pretty)

def request(method):
	"""
	All of the scripts wind up calling this function to handle their common functionality.

	:param method: The name of the request method to use (case-insensitive)
	:returns: The program's exit code
	"""
	try:
		s, path, data, full, raw, pretty = parse_arguments("to%s" % method)
	except (PermissionError, KeyError, ConnectionError) as e:
		print(e, file=sys.stderr)
		return 1

	if raw:
		path = '/'.join((s.to_url.rstrip('/'), path.lstrip('/')))
	else:
		path = '/'.join((s.base_url.rstrip('/'), path.lstrip('/')))

	try:
		if data is not None:
			r = s._session.request(method, path, data=data)
		else:
			r = s._session.request(method, path)
	except (RequestException, ValueError) as e:
		print("Error occurred: ", e, file=sys.stderr)
		return 2

	output(r, pretty, *full)
	return 0 if r.status_code < 400 else r.status_code // 100

def get():
	"""
	Entry point for :program:`toget`

	:returns: The program's exit code
	"""
	return request("get")

def put():
	"""
	Entry point for :program:`toput`

	:returns: The program's exit code
	"""
	return request("put")

def post():
	"""
	Entry point for :program:`topost`

	:returns: The program's exit code
	"""
	return request("post")

def delete():
	"""
	Entry point for :program:`todelete`

	:returns: The program's exit code
	"""
	return request("delete")

def options():
	"""
	Entry point for :program:`tooptions`

	:returns: The program's exit code
	"""
	return request("options")

def head():
	"""
	Entry point for :program:`tohead`

	:returns: The program's exit code
	"""
	return request("head")

def patch():
	"""
	Entry point for :program:`topatch`

	:returns: The program's exit code
	"""
	return request("patch")
