# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2007-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.
#
# Author: Alessandro Decina <alessandro@fluendo.com>
# Author: Olivier Tilloy <olivier@fluendo.com>

from twisted.internet import reactor, defer, error

# This is how twisted.internet.ssl should be imported, as documented at the
# beginning of the file, if we don't want a strong dependency on PyOpenSSL
try:
   from twisted.internet import ssl
except ImportError:
   # happens the first time the interpreter tries to import it
   ssl = None
if ssl and not ssl.supported:
   # happens second and later times
   ssl = None

from twisted.internet.protocol import ClientFactory
from twisted.web2 import responsecode

from elisa.core.utils.cancellable_defer import CancellableDeferred, \
                                               CancelledError
from elisa.core.media_uri import MediaUri

from elisa.plugins.http_client.extern.client_http \
    import HTTPClientProtocol, ClientRequest
from elisa.plugins.http_client.extern.channel_http \
    import PERSIST_PIPELINE, PERSIST_NO_PIPELINE

from elisa.core.log import Loggable

try:
    from OpenSSL import SSL
except ImportError:
    SSL = None

import platform


class ElisaHttpClientProtocol(HTTPClientProtocol):

    """
    A client-side specialization of the C{HTTPClientProtocol}.
    """

    def __init__(self, client):
        HTTPClientProtocol.__init__(self)
        self.client = client

    def connectionMade(self):
        self.client.connectionMade(self)

    def requestWriteFinished(self, request):
        HTTPClientProtocol.requestWriteFinished(self, request)
        self.client.requestWriteFinished(request)

    # requestReadFinished is not used here because it's called even when a read
    # is still going on and the connection is lost.


class ElisaHttpClientFactory(ClientFactory, Loggable):

    """
    A specialized HTTP client factory that automatically reconnects
    when disconnected from the server if the client has pending requests (with
    a maximum of 3 retries in case of failure).
    """

    # Give up after 3 retries
    maxRetries = 3

    def __init__(self, client):
        self.client = client
        self.retries = 0

    def buildProtocol(self, addr):
        """
        Build and return the HTTP client protocol.

        @param addr: HTTP host and port
        @type addr:  an implementation of
                     L{twisted.internet.interfaces.IAddress}

        @return:     an HTTP protocol instance
        @rtype:      L{ElisaHttpClientProtocol}
        """
        protocol = ElisaHttpClientProtocol(self.client)

        # FIXME: according to the specs (http://www.ietf.org/rfc/rfc2616.txt),
        # we are only allowed to assume a persistent connection in HTTP/1.1.
        # Checking if we received a 'Connection: Keep-Alive' header or if we
        # use HTTP/1.1 is necessary.
        if self.client.pipeline:
            persist = PERSIST_PIPELINE
        else:
            persist = PERSIST_NO_PIPELINE
        protocol.setReadPersistent(persist)

        return protocol

    def startedConnecting(self, connector):
        """
        Callback invoked when the connection is initiated.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        """
        self.log('HTTP client factory started connection on %s', connector)

    def clientConnectionFailed(self, connector, reason):
        """
        Callback invoked when the connection fails.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        @param reason:    the reason of the connection failure
        @type reason:     L{twisted.python.failure.Failure}
        """
        self.client.connectionFailed(connector, reason)
        # we don't want to reconnect in this case

    def clientConnectionLost(self, connector, reason):
        """
        Callback invoked when the connection is lost.
        This happens when the server decides to terminate the connection.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        @param reason:    the reason of the connection loss
        @type reason:     L{twisted.python.failure.Failure}
        """
        # Let the client requeue its requests
        if self.retries == self.maxRetries:
            self.client.connectionLostForever(connector, reason)
        else:
            if not self.client.connectionLost(connector, reason) or \
                not self.client.is_busy():
                # Do not reconnect if the client doesn't have pending requests
                return

        self._retry(connector)

    def _retry(self, connector):
        self.retries += 1
        if self.retries > self.maxRetries:
            self.log('Abandoning %s after %d retries' %
                     (connector, self.retries))
            return

        connector.connect()


class ElisaHttpClientNotOpenedError(Exception):

    """
    Exception raised when trying to close a connection that is not opened.
    """

    pass


class ElisaHttpClientNotPipeliningError(Exception):

    """
    Exception raised when trying to queue a request for a client that does not
    support request pipelining and that is already busy processing a request.
    """

    pass


class ElisaHttpClient(Loggable):

    """
    L{twisted.web2} based HTTP client.

    It connects to a given server and optionally supports request pipelining.
    It does not support HTTP redirections.
    """

    def __init__(self, host, port=80, pipeline=True, ssl_context=None):
        """
        Constructor.

        @param host:     hostname or IP address of the server
        @type host:      C{str}
        @param port:     TCP port on which the server listens
        @type port:      C{int}
        @param pipeline: whether the client should pipeline requests
        @type pipeline:  C{bool}
        @param ssl_context:  optional SSL context to use if we connect to a
                             https server (None by default).
        @type ssl_context:   C{twisted.internet.ssl.ClientContextFactory}
        """
        super(ElisaHttpClient, self).__init__()
        self._host = host
        self._port = port
        self.pipeline = pipeline
        self._ssl_context = ssl_context
        self._queued_requests = []
        self._written_requests = []
        self._closed = True
        self._reset()

    def _reset(self):
        self._connector = None
        self._protocol = None
        self._open_defer = defer.Deferred()
        self._close_defer = defer.Deferred()

    def _open(self):
        """
        Open an HTTP connection.

        This is a non-blocking method. One should wait for self._open_defer to
        be fired before attempting to send any request to the server.
        This method should not be called explicitely, the connection will be
        opened upon reception of the first request.
        """
        factory = ElisaHttpClientFactory(self)
        self.debug("opening connection to %s:%s", self._host, self._port)
        if self._ssl_context:
            self._connector = reactor.connectSSL(self._host, self._port,
                                                 factory, self._ssl_context)
        else:
            self._connector = reactor.connectTCP(self._host, self._port,
                                                 factory)

    def request(self, uri, method='GET', headers={}, stream=None):
        """
        Send an HTTP request.

        @param uri:    the URI of the resource to request
        @type uri:     C{str}
        @param method: the HTTP method of the request (default: GET)
        @type method:  C{str}
        @param headers: optional headers to send to the server or C{{}}
                        (the default)
        @type headers: C{dict} or L{twisted.web2.http_headers.Headers}
        @param stream: optional content body to send to the server
        @type stream:  L{twisted.web2.stream.IByteStream}

        @return:       a deferred triggered when the request is executed
        @rtype:        L{elisa.core.utils.cancellable_defer.CancellableDeferred}
        """
        return self.request_full(ClientRequest(method, uri, headers, stream))

    def request_full(self, request):
        """
        Send an HTTP request.

        @param request: request to submit
        @type request:  L{elisa.plugins.http_client.extern.client_http.ClientRequest}

        @return:        a deferred triggered when the request is executed
        @rtype:         L{elisa.core.utils.cancellable_defer.CancellableDeferred}
        """
        # set the 'Host' header if it is not set yet
        if not request.headers.getHeader('Host'):
            request.headers.setHeader('Host', self._host)
        return self._queue_request(request)

    def close(self):
        """
        Close an open HTTP connection.

        @return: a deferred triggered when the connection is closed
        @rtype:  L{twisted.internet.defer.Deferred}
        """
        if self._closed or not self._connector:
            # The connection is not open (yet)
            if self._connector and self._connector.state != 'disconnected':
                # ...but it was being opened for a pending request
                self._connector.disconnect()
                return self._close_defer
            return defer.fail(ElisaHttpClientNotOpenedError())

        self._closed = True

        if self._connector.state == 'disconnected':
            return defer.succeed(None)
        else:
            self._connector.disconnect()

        return self._close_defer

    def is_busy(self):
        """
        Test whether the client is busy processing requests.

        @return: C{True} if the client is busy, C{False} otherwise
        @rtype:  C{bool}
        """
        if self.pipeline:
            return bool(self._queued_requests)
        else:
            return bool(self._queued_requests or self._written_requests)

    def _queue_request(self, request):
        """
        Enqueue a request to submit to the server.

        @param request: request to submit
        @type request:  L{twisted.web2.client.http.ClientRequest}

        @return:        a deferred triggered when the request is executed
        @rtype:         L{elisa.core.utils.cancellable_defer.CancellableDeferred}
        """
        busy = self.is_busy()
        if not self.pipeline and busy:
            # Client busy and not pipelining
            return defer.fail(ElisaHttpClientNotPipeliningError())

        def _cancel_request(deferred):
            deferred.errback(CancelledError('Cancelled request'))

        request.defer = CancellableDeferred(canceller=_cancel_request)
        self._queued_requests.append(request)

        if not self._closed  and self._protocol is not None:
            self._submit_next_request()
        elif len(self._queued_requests) == 1:
            # Open the connection before submitting the request.
            # Do it only if there is exactly one request queued, because
            # otherwise the connection is already being opened.
            self._open()
            self._open_defer.addCallback(self._submit_next_request)

        return request.defer

    # This method is used as a deferred callback so it needs a result parameter
    def _submit_next_request(self, result=None):
        try:
            request = self._queued_requests[0]
        except IndexError:
            # No more requests to dequeue
            return

        if request.defer.called:
            # The deferred has been cancelled, ignore the request and go on
            # with the next one in the queue
            self._queued_requests.pop(0)
            return self._submit_next_request()

        def request_failure(failure, request):
            # When pipelining the server might decide to close the connection,
            # in which case we want to retry.
            self._trap_failure(failure)
            self.debug('request %s failed: %s. (going to be resubmitted)',
                       request, failure.getErrorMessage())
            self._open_defer.addCallback(self._submit_next_request)

        request_dfr = self._protocol.submitRequest(request,
            closeAfter=not self.pipeline)
        request_dfr.addCallback(self._request_done, request)
        # FIXME: we now chain the request defer here, so client code is called
        # when this request is still being processed, which make some sense. On
        # the other hand, if a client wants to submit another request in this
        # callback and it isn't pipelining, it must do it in
        # reactor.callLater(0, do_next_request) not to get a NotPipeliningError
        request_dfr.addCallback(self._callback_request_defer, request)
        request_dfr.addErrback(request_failure, request)
        return request_dfr

    def _callback_request_defer(self, result, request):
        if not request.defer.called:
            request.defer.callback(result)

    def connectionFailed(self, connector, reason):
        """
        Callback invoked by the HTTP client factory when the connection fails.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        @param reason:    the reason of the connection failure
        @type reason:     L{twisted.python.failure.Failure}
        """
        # Errback all the currently queued requests
        self.connectionLostForever(connector, reason)

    def connectionLost(self, connector, reason):
        """
        Callback invoked by the HTTP client factory when losing the connection.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        @param reason:    the reason of the connection loss
        @type reason:     L{twisted.python.failure.Failure}

        @return:          False if the connection has been closed,
                          True otherwise.
        @rtype:           C{bool}
        """
        self._protocol = None

        if self._closed:
            # FIXME: this part is hackish but I need a way to let the caller
            # re-enter and finish its work otherwise after a .close() on the
            # client the reactor is still in a dirty state and trial complains
            # about that.
            reactor.callLater(0, self._close_defer.callback, None)
            return False

        # The factory is going to retry a connection, re-enqueue unprocessed
        # requests.
        requests = []
        for request in self._written_requests + self._queued_requests:
            copy = request.copy()
            copy.defer = request.defer
            requests.append(copy)
        self._written_requests = []
        self._queued_requests = requests
        return not self._closed

    def connectionLostForever(self, connector, reason):
        """
        Callback invoked by the HTTP client factory when the connection is lost
        forever.
        This happens when the maximum number of connection retries is reached.

        @param connector: the TCP connector
        @type connector:  L{twisted.internet.tcp.Connector}
        @param reason:    the reason of the last connection loss
        @type reason:     L{twisted.python.failure.Failure}
        """
        self._protocol = None
        written_requests, self._written_requests = \
                self._written_requests, []
        queued_requests, self._queued_requests = \
                self._queued_requests, []

        # errback unlocked
        for request in written_requests + queued_requests:
            request.defer.errback(reason)
        reactor.callLater(0, self._close_defer.callback, None)

    def connectionMade(self, protocol):
        """
        Callback invoked by the HTTP client protocol when the connection is
        established.

        @param protocol: the HTTP client protocol
        @type protocol:  L{ElisaHttpClientProtocol}
        """
        assert self._protocol is None
        self._protocol = protocol

        open_defer, self._open_defer = self._open_defer, defer.Deferred()
        self._closed = False
        if open_defer.callbacks:
            reactor.callLater(0, open_defer.callback, protocol)

    def requestWriteFinished(self, req):
        """
        Callback invoked by the HTTP client protocol when a request has been
        fully written.

        @param req: the channel request that has been written
        @type req:  L{http_client.extern.client_http.HTTPClientChannelRequest}
        """
        request = self._queued_requests.pop(0)
        assert request == req.request
        self._written_requests.append(request)

        if self.pipeline and self._queued_requests:
            self._submit_next_request()

    def _request_done(self, response, request):
        """
        Callback invoked when receiving the response to a request.
        """
        assert request == self._written_requests.pop(0)

        # Reset the number of retries only when one request has been correctly
        # processed
        self._connector.factory.retries = 0

        self._submit_next_request()
        return response

    def _trap_failure(self, failure):
        """
        Catch ConnectionDone error and the generic SSL Error (if we
        have SSL support).
        """
        errors = [error.ConnectionDone,]
        if SSL:
            errors.append(SSL.Error)
            # on windows platform twisted's ssl layer also raises
            # ConnectionLost
            if platform.system() == 'Windows':
                errors.append(error.ConnectionLost)
        failure.trap(*errors)

# See http://www.w3.org/Protocols/HTTP/HTRESP.html#z10 for details on HTTP
# redirect response codes.
REDIRECTION_CODES = (responsecode.MULTIPLE_CHOICE, # 300
                     responsecode.MOVED_PERMANENTLY, # 301
                     responsecode.FOUND, # 302
                     responsecode.SEE_OTHER, # 303
                     responsecode.NOT_MODIFIED, # 304
                     responsecode.USE_PROXY, # 305
                     responsecode.TEMPORARY_REDIRECT #307
                    )

class ElisaAdvancedHttpClient(ElisaHttpClient):

    """
    An ElisaHttpClient specialization that handles HTTP redirections.
    """

    def __init__(self, host, port=80, ssl_context=None):
        super(ElisaAdvancedHttpClient, self).__init__(host, port,
                                                      pipeline=True,
                                                      ssl_context=ssl_context)
        self._redirection_client = None

    def _create_redirection_client(self, result_or_failure, uri):
        if uri.scheme == 'https':
            if ssl is None:
                raise RuntimeError("ssl not available. Do you have " \
                                   "PyOpenSSL?")
            default_port = 443
            ssl_context = self._ssl_context or ssl.ClientContextFactory()
        else:
            default_port = 80
            ssl_context = None
        port = uri.port or default_port
        host = uri.host
        self._redirection_client = ElisaAdvancedHttpClient(host, port,
                                                           ssl_context=ssl_context)

    def close(self):
        try:
            dfr = self._redirection_client.close()
        except AttributeError:
            return super(ElisaAdvancedHttpClient, self).close()
        else:
            dfr.addBoth(lambda result_or_failure: \
                        super(ElisaAdvancedHttpClient, self).close())
            return dfr

    def _request_done(self, response, request):
        """
        Callback invoked when receiving the response to a request.
        """
        if response.code in REDIRECTION_CODES:
            # HTTP redirection
            # FIXME: handle redirection loops
            location = response.headers.getHeader('location')
            # According to the HTTP specification
            # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.30
            # this is not allowed: the location header should contain
            # an absolute URL. But it is reasonable to assume that
            # some servers don't implement the spec correctly
            if location.startswith('/'):
                # No host provided, assuming the same host and port
                same_host = True
                if self._ssl_context:
                    scheme = 'https'
                else:
                    scheme = 'http'
                location = '%s://%s:%d%s' % (scheme, self._host, self._port,
                                             location)
            else:
                location_uri = MediaUri(location)
                same_host = (location_uri.host == self._host)

            assert request == self._written_requests.pop(0)
            self._connector.factory.retries = 0

            redirection = ClientRequest(request.method, location, {}, None)
            redirection.headers = request.headers

            if same_host:
                if redirection.headers.getHeader('host') is None:
                    redirection.headers.setHeader('host', self._host)

                redirection.defer = defer.Deferred()
                self._queued_requests.insert(0, redirection)

                self._submit_redirection_request(redirection)
                return redirection.defer
            else:
                def do_redirection_request(result_or_failure=None):
                    redirection.headers.setHeader('host', str(location_uri.host))
                    redirection_dfr = \
                        self._redirection_client.request_full(redirection)
                    return redirection_dfr

                if self._redirection_client is not None:
                    if self._redirection_client._host != str(location_uri.host) or \
                        self._redirection_client._port != location_uri.port:
                        close_dfr = self._redirection_client.close()
                        close_dfr.addBoth(self._create_redirection_client,
                                          location_uri)
                        close_dfr.addCallback(do_redirection_request)
                        return close_dfr
                    else:
                        # Reuse the same redirection HTTP client
                        return do_redirection_request()
                else:
                    self._create_redirection_client(None, location_uri)
                    return do_redirection_request()
        else:
            # Normal response
            assert request == self._written_requests.pop(0)
            self._connector.factory.retries = 0
            self._submit_next_request()
            return response

    def _submit_redirection_request(self, request):
        def request_failure(failure, request):
            # When pipelining the server might decide to close the connection,
            # in which case we want to retry.
            self._trap_failure(failure)
            self.debug('request %s failed: %s. (going to be resubmitted)',
                       request, failure)
            self._open_defer.addCallback(self._submit_next_request)

        request_dfr = self._protocol.submitRequest(request, closeAfter=False)
        request_dfr.addCallback(self._request_done, request)
        # FIXME: we now chain the request defer here, so client code is called
        # when this request is still being processed, which make some sense. On
        # the other hand, if a client wants to submit another request in this
        # callback and it isn't pipelining, it must do it in
        # reactor.callLater(0, do_next_request) not to get a NotPipeliningError
        request_dfr.addCallback(request.defer.callback)
        request_dfr.addErrback(request_failure, request)
        return request_dfr
