# pylint: disable=missing-module-docstring,invalid-name
# Shared (Spacewalk Proxy/Redirect) handler code called by rhnApache.
#
# Copyright (c) 2008--2017 Red Hat, Inc.
#
# This software is licensed to you under the GNU General Public License,
# version 2 (GPLv2). There is NO WARRANTY for this software, express or
# implied, including the implied warranties of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
# along with this software; if not, see
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
#
# Red Hat trademarks are not licensed under GPLv2. No permission is
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#

# language imports
try:
    # python 3
    import urllib.parse as urllib
except ImportError:
    # python 2
    import urllib
import socket
import sys

# global imports
from rhn import connections
from rhn.SSL import TimeoutException
from rhn.SmartIO import SmartIO

# common imports
from rhn.UserDictCase import UserDictCase
from spacewalk.common.rhnTB import Traceback
from spacewalk.common.rhnConfig import CFG
from spacewalk.common.rhnException import rhnFault, rhnException
from spacewalk.common.rhnLog import log_debug, log_error
from spacewalk.common import rhnFlags, apache
from spacewalk.common.rhnTranslate import _
from uyuni.common import rhnLib
from uyuni.common.usix import raise_with_tb, ListType, TupleType

# local imports
from . import rhnConstants
from .responseContext import ResponseContext

PRODUCT_NAME = "SUSE Multi-Linux Manager"


class SharedHandler:
    """Shared handler class (between rhnBroker and rhnRedirect.
    *** only inherited ***
    """

    # pylint: disable=R0902,R0903
    def __init__(self, req):
        """init with http request object"""

        # FIXME: should rename some things:
        #        self.bodyFd --> self.body or self.data or ?
        #        self.caChain --> self.caCert

        self.req = req
        # turn wsgi.input object into a SmartIO instance so it can be read
        # more than once
        if "wsgi.input" in self.req.headers_in:
            # pylint: disable-next=invalid-name
            smartFd = SmartIO(max_mem_size=CFG.MAX_MEM_FILE_SIZE)
            smartFd.write(self.req.headers_in["wsgi.input"].read())
            self.req.headers_in["wsgi.input"] = smartFd

        # pylint: disable-next=invalid-name
        self.responseContext = ResponseContext()
        self.uri = None  # ''

        # Common settings for both the proxy and the redirect
        # broker and redirect immediately alter these for their own purposes
        # pylint: disable-next=invalid-name
        self.caChain = CFG.CA_CHAIN
        # pylint: disable-next=invalid-name
        self.httpProxy = CFG.HTTP_PROXY
        # pylint: disable-next=invalid-name
        self.httpProxyUsername = CFG.HTTP_PROXY_USERNAME
        # pylint: disable-next=invalid-name
        self.httpProxyPassword = CFG.HTTP_PROXY_PASSWORD
        if not self.httpProxyUsername:
            self.httpProxyPassword = ""
        # pylint: disable-next=invalid-name
        self.rhnParent = CFG.RHN_PARENT or ""
        self.rhnParent = rhnLib.parseUrl(self.rhnParent)[1].split(":")[0]
        CFG.set("RHN_PARENT", self.rhnParent)

        # can we resolve self.rhnParent?
        # BUG 148961: not necessary, and dumb if the proxy is behind a firewall

    #        try:
    #            socket.gethostbyname(self.rhnParent)
    #        except socket.error, e:
    #            msg = "SOCKET ERROR: hostname: %s - %s" % (self.rhnParent, str(e))
    #            log_error(msg)
    #            log_debug(0, msg)
    #            raise

    # --- HANDLER SPECIFIC CODE ---

    # pylint: disable-next=invalid-name
    def _prepHandler(self):
        """Handler part 0"""

        # Just to be on the safe side
        if self.req.main:
            # A subrequest
            return apache.DECLINED
        log_debug(4, rhnFlags.all())

        if not self.rhnParent:
            raise rhnException("Oops, no proxy parent! Exiting")

        # Copy the headers.
        rhnFlags.get("outputTransportOptions").clear()
        rhnFlags.get("outputTransportOptions").update(self._getHeaders(self.req))

        return apache.OK

    # pylint: disable-next=invalid-name
    def _connectToParent(self):
        """Handler part 1
        Should not return an error code -- simply connects.
        """

        scheme, host, port, self.uri, query = self._parse_url(self.rhnParent)
        self.responseContext.setConnection(self._create_connection())

        if not self.uri:
            self.uri = "/"

        # if this request is for an upstream server, use the original query string.
        # Otherwise, if it is for the local Squid instance, strip it so that
        # Squid will not keep multiple cached copies of the same resource
        # Containers notes: when going for local proxy, use localhost as host to avoid
        # hairpin problem.
        if self.httpProxy not in ["127.0.0.1:8080", "localhost:8080"]:
            if "X-Suse-Auth-Token" in self.req.headers_in:
                # pylint: disable-next=consider-using-f-string
                self.uri += "?%s" % self.req.headers_in["X-Suse-Auth-Token"]
            elif query:
                # pylint: disable-next=consider-using-f-string
                self.uri += "?%s" % query
        else:
            host = "localhost"

        log_debug(3, "Scheme:", scheme)
        log_debug(3, "Host:", host)
        log_debug(3, "Port:", port)
        log_debug(3, "URI:", self.uri)
        log_debug(3, "HTTP proxy:", self.httpProxy)
        log_debug(3, "HTTP proxy username:", self.httpProxyUsername)
        log_debug(3, "HTTP proxy password:", "<password>")
        log_debug(3, "CA cert:", self.caChain)

        try:
            self.responseContext.getConnection().connect()
        except socket.error as e:
            log_error("Error opening connection", self.rhnParent, e)
            Traceback(mail=0)
            raise_with_tb(
                rhnFault(
                    1000,
                    _(
                        f"{PRODUCT_NAME} Proxy could not successfully connect its {PRODUCT_NAME} parent. "
                        "Please contact your system administrator."
                    ),
                ),
                sys.exc_info()[2],
            )

        # At this point the server should be okay
        # pylint: disable-next=consider-using-f-string
        log_debug(3, "Connected to parent: %s " % self.rhnParent)
        if self.httpProxy:
            if self.httpProxyUsername:
                log_debug(
                    3,
                    # pylint: disable-next=consider-using-f-string
                    "HTTP proxy info: %s %s/<password>"
                    % (self.httpProxy, self.httpProxyUsername),
                )
            else:
                # pylint: disable-next=consider-using-f-string
                log_debug(3, "HTTP proxy info: %s" % self.httpProxy)
        else:
            log_debug(3, "HTTP proxy info: not using an HTTP proxy")
        peer = self.responseContext.getConnection().sock.getpeername()
        # pylint: disable-next=consider-using-f-string
        log_debug(4, "Other connection info: %s:%s%s" % (peer[0], peer[1], self.uri))

    def _create_connection(self):
        """Returns a Connection object"""
        # pylint: disable-next=invalid-name,invalid-name,unused-variable,unused-variable
        scheme, host, port, _uri, _query = self._parse_url(self.rhnParent)
        # Build the list of params
        params = {
            "host": host,
            "port": port,
        }

        # Containers notes: when going for local proxy, use localhost as host to avoid
        # hairpin problem.
        if self.httpProxy in ["127.0.0.1:8080", "localhost:8080"]:
            params["host"] = "localhost"

        if CFG.has_key("timeout"):
            params["timeout"] = CFG.TIMEOUT
        if self.httpProxy:
            params["proxy"] = self.httpProxy
            params["username"] = self.httpProxyUsername
            params["password"] = self.httpProxyPassword
        if scheme == "https" and self.caChain:
            params["trusted_certs"] = [
                self.caChain,
            ]

        # Now select the right class
        if self.httpProxy:
            if scheme == "https":
                conn_class = connections.HTTPSProxyConnection
            else:
                conn_class = connections.HTTPProxyConnection
        else:
            if scheme == "https":
                conn_class = connections.HTTPSConnection
            else:
                conn_class = connections.HTTPConnection

        log_debug(5, "Using connection class", conn_class, "Params:", params)
        return conn_class(**params)

    @staticmethod
    def _parse_url(url):
        """Returns scheme, host, port, path, query."""
        # pylint: disable-next=invalid-name,invalid-name,unused-variable,unused-variable
        scheme, netloc, path, _params, query, _frag = rhnLib.parseUrl(url)
        host, port = urllib.splitnport(netloc)
        if port <= 0:
            port = None
        return scheme, host, port, path, query

    # pylint: disable-next=invalid-name
    def _serverCommo(self):
        """Handler part 2

        Server (or next proxy) communication.
        """

        log_debug(2)

        # Copy the method from the original request, and use the
        # handler for this server
        # We add path_info to the put (GET, CONNECT, HEAD, PUT, POST) request.
        log_debug(2, self.req.method, self.uri)
        self.responseContext.getConnection().putrequest(self.req.method, self.uri)

        # Send the headers, the body and expect a response
        try:
            # pylint: disable-next=invalid-name
            status, headers, bodyFd = self._proxy2server()
            self.responseContext.setHeaders(headers)
            self.responseContext.setBodyFd(bodyFd)
        except IOError:
            # Raised by HTTP*Connection.getresponse
            # Server closed connection on us, no need to mail out
            # XXX: why are we not mailing this out???
            Traceback("SharedHandler._serverCommo", self.req, mail=0)
            raise_with_tb(
                rhnFault(
                    1000,
                    _(
                        f"{PRODUCT_NAME} Proxy error: connection with the {PRODUCT_NAME} server failed"
                    ),
                ),
                sys.exc_info()[2],
            )
        except socket.error:  # pylint: disable=duplicate-except
            # maybe self.req.read() failed?
            Traceback("SharedHandler._serverCommo", self.req)
            raise_with_tb(
                rhnFault(
                    1000,
                    _(
                        f"{PRODUCT_NAME} Proxy error: connection with the {PRODUCT_NAME} server failed"
                    ),
                ),
                sys.exc_info()[2],
            )

        # pylint: disable-next=consider-using-f-string
        log_debug(2, "HTTP status code (200 means all is well): %s" % status)

        # Now we need to decide how to deal with the server's response.  We'll
        # defer to subclass-specific implementation here.  The handler will
        # return apache.OK if the request was a success.

        return self._handleServerResponse(status)

    # pylint: disable-next=invalid-name
    def _handleServerResponse(self, status):
        """This method can be overridden by subclasses who want to handle server
        responses in their own way.  By default, we will wrap all the headers up
        and send them back to the client with an error status.  This method
        should return apache.OK if everything went according to plan.
        """
        if status not in (apache.HTTP_OK, apache.HTTP_PARTIAL_CONTENT):
            # Non 200 response; have to treat it differently
            # pylint: disable-next=consider-using-f-string
            log_debug(2, "Forwarding status %s" % status)
            # Copy the incoming headers to headers_out
            headers = self.responseContext.getHeaders()
            if headers is not None:
                for k in list(headers.keys()):
                    rhnLib.setHeaderValue(self.req.headers_out, k, self._get_header(k))
            else:
                log_error("WARNING? - no incoming headers found!")
            # And that's that
            return status

        if status == apache.HTTP_PARTIAL_CONTENT:
            return apache.HTTP_PARTIAL_CONTENT

        # apache.HTTP_OK becomes apache.OK.
        return apache.OK

    # pylint: disable-next=invalid-name
    def _get_header(self, k, headerObj=None):
        if headerObj is None:
            headerObj = self.responseContext.getHeaders()

        if hasattr(headerObj, "getheaders"):
            return headerObj.getheaders(k)

        return headerObj.get_all(k)

    # pylint: disable-next=invalid-name
    def _clientCommo(self, status=apache.OK):
        """Handler part 3
        Forward server's response to the client.
        """
        log_debug(2)

        try:
            self._forwardServer2Client()
        except IOError:
            # Raised by HTTP*connection.getresponse
            # Client closed connection on us, no need to mail out a traceback
            Traceback("SharedHandler._clientCommo", self.req, mail=0)
            return apache.HTTP_SERVICE_UNAVAILABLE

        # Close all open response contexts.
        self.responseContext.clear()

        return status

    # --- PROTECTED METHODS ---

    @staticmethod
    # pylint: disable-next=invalid-name
    def _getHeaders(req):
        """Copy the incoming headers."""

        hdrs = UserDictCase()
        for k in list(req.headers_in.keys()):
            # XXX misa: is this enough? Shouldn't we care about multivalued
            # headers?
            hdrs[k] = req.headers_in[k]
        return hdrs

    # pylint: disable-next=invalid-name
    def _forwardServer2Client(self):
        """Forward headers, and bodyfd from server to the calling client.
        For most XMLRPC code, this function is called.
        """

        log_debug(2)

        # Okay, nothing interesting from the server;
        # we'll just forward what we got

        # pylint: disable-next=invalid-name
        bodyFd = self.responseContext.getBodyFd()

        self._forwardHTTPHeaders(bodyFd, self.req)

        # Set the content type

        headers = self.responseContext.getHeaders()
        self.req.content_type = headers.get_content_type()
        self.req.send_http_header()

        # Forward the response body back to the client.

        self._forwardHTTPBody(bodyFd, self.req)

    def _proxy2server(self):
        hdrs = rhnFlags.get("outputTransportOptions")
        log_debug(3, hdrs)
        size = -1

        # Put the headers into the output connection object
        http_connection = self.responseContext.getConnection()
        for k, vals in list(hdrs.items()):
            if k.lower() in ["content_length", "content-length"]:
                try:
                    size = int(vals)
                except ValueError:
                    pass
            if k.lower() in ["content_length", "content_type"]:
                # mod_wsgi modifies incoming headers so we have to transform them back
                k = k.replace("_", "-")
            if not (
                k.lower().startswith("x-")
                or k.lower()
                in [  # all but 'host', and 'via'
                    "accept",
                    "accept-charset",
                    "accept-encoding",
                    "accept-language",
                    "accept-ranges",
                    "age",
                    "allow",
                    "authorization",
                    "cache-control",
                    "connection",
                    "content-encoding",
                    "content-language",
                    "content-length",
                    "content-location",
                    "content-md5",
                    "content-range",
                    "content-type",
                    "date",
                    "etag",
                    "expect",
                    "expires",
                    "from",
                    "if-match",
                    "if-modified-since",
                    "if-none-match",
                    "if-range",
                    "if-unmodified-since",
                    "last-modified",
                    "location",
                    "max-forwards",
                    "pragma",
                    "proxy-authenticate",
                    "proxy-authorization",
                    "range",
                    "referer",
                    "retry-after",
                    "server",
                    "te",
                    "trailer",
                    "transfer-encoding",
                    "upgrade",
                    "user-agent",
                    "vary",
                    "warning",
                    "www-authenticate",
                ]
            ):
                # filter out header we don't want to send
                continue
            if not isinstance(vals, (ListType, TupleType)):
                vals = [vals]
            for v in vals:
                log_debug(5, "Outgoing header", k, v)
                http_connection.putheader(k, v)
        http_connection.endheaders()

        # Send the body too if there is a body
        if size > 0:
            # reset file to beginning so it can be read again
            self.req.headers_in["wsgi.input"].seek(0, 0)
            if sys.version_info < (2, 6):
                data = self.req.headers_in["wsgi.input"].read(size)
            else:
                data = self.req.headers_in["wsgi.input"]
            http_connection.send(data)

        # At this point everything is sent to the server
        # We now wait for the response
        try:
            response = http_connection.getresponse()
        except TimeoutException:
            log_error("Connection timed out")
            return apache.HTTP_GATEWAY_TIME_OUT, None, None
        headers = response.msg
        status = response.status
        # Get the body of the request too - well, just a fd actually
        # in this case, the response object itself.
        # pylint: disable-next=invalid-name
        bodyFd = response
        return status, headers, bodyFd

    # pylint: disable-next=invalid-name
    def _getEffectiveURI(self):
        if rhnConstants.HEADER_EFFECTIVE_URI in self.req.headers_in:
            return self.req.headers_in[rhnConstants.HEADER_EFFECTIVE_URI]

        return self.req.uri

    @staticmethod
    # pylint: disable-next=invalid-name
    def _determineHTTPBodySize(headers):
        """This routine attempts to determine the size of an HTTP body by searching
        the headers for a "Content-Length" field.  The size is returned, if
        found, otherwise -1 is returned.
        """

        # Get the size of the body
        size = 0
        if rhnConstants.HEADER_CONTENT_LENGTH in headers:
            try:
                size = int(headers[rhnConstants.HEADER_CONTENT_LENGTH])
            except ValueError:
                size = -1
        else:
            size = -1

        return size

    # pylint: disable-next=invalid-name,invalid-name,invalid-name
    def _forwardHTTPHeaders(self, fromResponse, toRequest):
        """This routine will transfer the header contents of an HTTP response to
        the output headers of an HTTP request for reply to the original
        requesting client.  This function does NOT call the request's
        send_http_header routine; that is the responsibility of the caller.
        """

        if fromResponse is None or toRequest is None:
            return

        # Iterate over each header in the response and place it in the request
        # output area.

        for k in list(fromResponse.msg.keys()):
            # Get the value
            v = self._get_header(k, fromResponse.msg)

            if (k.lower() == "transfer-encoding") and ("chunked" in v):
                log_debug(5, "Filtering header", k, v)
                continue

            # Set the field in the response

            rhnLib.setHeaderValue(toRequest.headers_out, k, v)

    # pylint: disable-next=invalid-name,invalid-name,invalid-name
    def _forwardHTTPBody(self, fromResponse, toRequest):
        """This routine will transfer the body of an HTTP response to the output
        area of an HTTP request for response to the original requesting client.
        The request's send_http_header function must be called before this
        function is called.
        """
        if fromResponse is None or toRequest is None:
            return

        # Get the size of the body

        size = self._determineHTTPBodySize(fromResponse.msg)
        log_debug(4, "Response body size: ", size)

        # Now fill in the bytes if need be.

        # read content if there is some or the size is unknown
        if (size > 0 or size == -1) and (toRequest.method != "HEAD"):
            tfile = SmartIO(max_mem_size=CFG.MAX_MEM_FILE_SIZE)
            buf = fromResponse.read(CFG.BUFFER_SIZE)
            while buf:
                try:
                    tfile.write(buf)
                    buf = fromResponse.read(CFG.BUFFER_SIZE)
                except IOError:
                    buf = 0
            tfile.seek(0)
            if "wsgi.file_wrapper" in toRequest.headers_in:
                toRequest.output = toRequest.headers_in["wsgi.file_wrapper"](
                    tfile, CFG.BUFFER_SIZE
                )
            else:
                toRequest.output = iter(lambda: tfile.read(CFG.BUFFER_SIZE), "")
