"""
open/DurusWorks/qp/http/response.py

"""
from durus.utils import join_bytes, as_bytes
from qp.lib.spec import unicode_string
import struct
import time
import zlib
import sys

if sys.version < "3":
    from rfc822 import formatdate
else:
    from email.utils import formatdate

status_reasons = {
    100: 'Continue',
    101: 'Switching Protocols',
    102: 'Processing',
    200: 'OK',
    201: 'Created',
    202: 'Accepted',
    203: 'Non-Authoritative Information',
    204: 'No Content',
    205: 'Reset Content',
    206: 'Partial Content',
    207: 'Multi-Status',
    300: 'Multiple Choices',
    301: 'Moved Permanently',
    302: 'Moved Temporarily',
    303: 'See Other',
    304: 'Not Modified',
    305: 'Use Proxy',
    307: 'Temporary Redirect',
    400: 'Bad Request',
    401: 'Unauthorized',
    402: 'Payment Required',
    403: 'Forbidden',
    404: 'Not Found',
    405: 'Method Not Allowed',
    406: 'Not Acceptable',
    407: 'Proxy Authentication Required',
    408: 'Request Time-out',
    409: 'Conflict',
    410: 'Gone',
    411: 'Length Required',
    412: 'Precondition Failed',
    413: 'Request Entity Too Large',
    414: 'Request-URI Too Large',
    415: 'Unsupported Media Type',
    416: 'Requested range not satisfiable',
    417: 'Expectation Failed',
    422: 'Unprocessable Entity',
    423: 'Locked',
    424: 'Failed Dependency',
    500: 'Internal Server Error',
    501: 'Not Implemented',
    502: 'Bad Gateway',
    503: 'Service Unavailable',
    504: 'Gateway Time-out',
    505: 'HTTP Version not supported',
    507: 'Insufficient Storage',
}

_GZIP_HEADER = as_bytes("\037\213" # magic
                "\010" # compression method
                "\000" # flags
                "\000\000\000\000" # time, who cares?
                "\002"
                "\377")

_GZIP_EXCLUDE = set(["application/pdf",
                     "application/zip",
                     "audio/mpeg",
                     "image/gif",
                     "image/jpeg",
                     "image/png",
                     "video/mpeg",
                     "video/quicktime",
                     "video/x-msvideo",
                     ])


class HTTPResponse (object):
    """
    An object representation of an HTTP response.

    The Response type encapsulates all possible responses to HTTP
    requests.

    Instance attributes:
      content_type : (mime_type:str, charset:str)
      status : (status_code:int, reason_phrase:str)
      headers : { string : string }
        most of the headers included with the response; every header set
        by 'set_header()' goes here.  Does not include "Status" or
        "Set-Cookie" headers (unless someone uses set_header() to set
        them, but that would be foolish).
      body : str | Stream
        the response body, None by default.  Note that if the body is not a
        stream then it is already encoded using 'charset'.
      buffered : bool
        if false, response data will be flushed as soon as it is
        written (the default is true).  This is most useful for
        responses that use the Stream() protocol.  Note that whether the
        client actually receives the partial response data is highly
        dependent on the web server
      cookies : { name:string : { attrname : value } }
        collection of cookies to set in this response; it is expected
        that the user-agent will remember the cookies and send them on
        future requests.  The cookie value is stored as the "value"
        attribute.  The other attributes are as specified by RFC 2109.
      cache : int | None
        the number of seconds the response may be cached.  The default is 0,
        meaning don't cache at all.  This variable is used to set the HTTP
        expires header.  If set to None then the expires header will not be
        added.
      compress : bool
        should the body of the response be compressed?
      range : None | (int|None, int|None)
    """

    def __init__(self):
        self.set_content_type('text/html', 'utf-8')
        self.set_status(200)
        self.set_compress(False)
        self.set_expires(0)
        self.set_buffered(True)
        self.headers = {}
        self.cookies = {}
        self.body = None
        self.range = None

    def set_content_type(self, mime_type, charset):
        """(mime_type:str, charset:str)
        """
        self.content_type = (mime_type, charset)

    def get_mime_type(self):
        return self.content_type[0]

    def get_charset(self):
        return self.content_type[1]

    def set_compress(self, value):
        self.compress = value

    def get_compress(self):
        return self.compress

    def set_buffered(self, value):
        self.buffered = value

    def get_buffered(self):
        return self.buffered

    def set_range(self, range):
        self.range = range
        if range is not None:
            self.set_status(206)

    def get_range(self):
        return self.range

    def set_status(self, status_code, reason=None):
        """(status_code : int, reason : string = None)

        Sets the HTTP status code of the response.  'status_code' must be an
        integer in the range 100 .. 599.  'reason' must be a string; if
        not supplied, the default reason phrase for 'status_code' will be
        used.  If 'status_code' is a non-standard status code, the generic
        reason phrase for its group of status codes will be used; eg.
        if status == 493, the reason for status 400 will be used.
        """
        if not isinstance(status_code, int):
            raise TypeError("status_code must be an integer")
        if not (100 <= status_code <= 599):
            raise ValueError("status_code must be between 100 and 599")
        self.status = (status_code,
                       (reason or
                        status_reasons.get(status_code) or
                        status_reasons.get(status_code - (status_code % 100))))

    def get_status(self):
        return self.status

    def get_status_code(self):
        return self.status[0]

    def set_header(self, name, value):
        """(name : string, value : string)"""
        self.headers[name] = value

    def get_header(self, name, default=None):
        """(name : string, default=None) -> value : string

        Gets an HTTP return header "name".  If none exists then 'default' is
        returned.
        """
        lower_name = name.lower()
        for header in self.headers:
            if header.lower() == lower_name:
                return self.headers[header]
        return default

    def set_expires(self, seconds=0, minutes=0, hours=0, days=0):
        if seconds is None:
            self.cache = None # don't generate 'Expires' header
        else:
            self.cache = seconds + 60*(minutes + 60*(hours + 24*days))

    def _compress_body(self, body):
        """(body: str) -> str
        Try to compress the body using gzip and return either the original
        body or the compressed body.  If the compressed body is returned, the
        content-encoding header is set to 'gzip'.
        """
        if not self.get_compress() or self.get_mime_type() in _GZIP_EXCLUDE:
            return body
        n = len(body)
        co = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS,
                              zlib.DEF_MEM_LEVEL, 0)
        chunks = [_GZIP_HEADER,
                  co.compress(body),
                  co.flush(),
                  struct.pack("<LL",
                    zlib.crc32(body) & 0xFFFFFFFF,
                    n & 0xFFFFFFFF)]
        compressed_body = join_bytes(chunks)
        ratio = float(n) / len(compressed_body)
        if ratio > 1.0:
            self.set_header("Content-Encoding", "gzip")
            return compressed_body
        else:
            return body

    def _encode_chunk(self, chunk):
        """(chunk:str|unicode_string)
        Returns the chunk encoded using the charset of the response.
        If the chunk is a str, it is assumed to be encoded already
        using the charset of the response.
        """
        if isinstance(chunk, unicode_string):
            return chunk.encode(self.get_charset())
        else:
            return chunk

    def set_body(self, body):
        """(body : Stream|unicode_string|byte_string)

        Sets the response body equal to the argument 'body'.
        If the argument is a str, or a Stream that generates str instances,
        it is the caller's responsibility to make sure that these str instances
        use the charset of the response.
        """
        if isinstance(body, Stream):
            self.body = body
            self.body.set_range(self.range)
        else:
            encoded = self._encode_chunk(body)
            self.body = self._compress_body(encoded)

    def expire_cookie(self, name, **attrs):
        """
        Cause an HTTP cookie to be removed from the browser

        The response will include an HTTP header that will remove the cookie
        corresponding to "name" on the client, if one exists.  This is
        accomplished by sending a new cookie with an expiration date
        that has already passed.  Note that some clients require a path
        to be specified - this path must exactly match the path given
        when creating the cookie.  The path can be specified as a keyword
        argument.
        """
        params = {'max_age': 0, 'expires': 'Thu, 01-Jan-1970 00:00:00 GMT'}
        params.update(attrs)
        self.set_cookie(name, "deleted", **params)

    def set_cookie(self, name, value, **attrs):
        """(name : string, value : string, **attrs)

        Set an HTTP cookie on the browser.

        The response will include an HTTP header that sets a cookie on
        cookie-enabled browsers with a key "name" and value "value".
        Cookie attributes such as "expires" and "domains" may be
        supplied as keyword arguments; see RFC 2109 for a full list.
        (For the "secure" attribute, use any true value.)

        This overrides any previous value for this cookie.  Any
        previously-set attributes for the cookie are preserved, unless
        they are explicitly overridden with keyword arguments to this
        call.
        """
        cookies = self.cookies
        if name in cookies:
            cookie = cookies[name]
        else:
            cookie = cookies[name] = {}
        cookie.update(attrs)
        cookie['value'] = value

    def get_content_length(self):
        if self.body is None:
            return None
        if isinstance(self.body, Stream):
            return self.body.get_length()
        # self.body is a string.
        length = len(self.body)
        if self.range is None:
            return length
        first_byte, last_byte = self.range
        if last_byte is None:
            return length - 1
        elif first_byte is None:
            return min(length - 1, last_byte)
        else:
            return last_byte - first_byte + 1

    def _gen_cookie_headers(self):
        """() -> [(str, str)]
        """
        for name, attrs in self.cookies.items():
            value = str(attrs['value'])
            if '"' in value:
                value = value.replace('"', '\\"')
            chunks = ['%s="%s"' % (name, value)]
            for name, val in attrs.items():
                name = name.lower()
                if val is None:
                    continue
                if name in ('expires', 'domain', 'path', 'max_age', 'comment'):
                    name = name.replace('_', '-')
                    chunks.append('%s=%s' % (name, val))
                elif name == 'secure' and val:
                    chunks.append("secure")
            yield ("Set-Cookie", '; '.join(chunks))


    def generate_headers(self):
        """() -> [(name:string, value:string)]

        Generate a list of headers to be returned as part of the response.
        """
        for name, value in self.headers.items():
            yield (as_bytes(name.title()), as_bytes(value))

        yield (as_bytes("Accept-Ranges"), as_bytes("bytes"))

        for name, value in self._gen_cookie_headers():
            yield as_bytes(name), as_bytes(value)

        # Date header
        now = time.time()
        if "date" not in self.headers:
            yield (as_bytes("Date"), as_bytes(formatdate(now)))

        # Cache directives
        if self.cache is None:
            pass # don't mess with the expires header
        elif "expires" not in self.headers:
            if self.cache > 0:
                expire_date = formatdate(now + self.cache)
            else:
                expire_date = "-1" # allowed by HTTP spec and may work better
                                   # with some clients
            yield (as_bytes("Expires"), as_bytes(expire_date))

        # Content-type
        if "content-type" not in self.headers:
            mime_type, charset = self.content_type
            value = mime_type
            if mime_type:
                if charset:
                    value += '; charset=%s' % charset
                yield (as_bytes('Content-Type'), as_bytes(value))

        # Content-Length
        if "content-length" not in self.headers:
            length = self.get_content_length()
            if length is not None:
                yield (as_bytes('Content-Length'), as_bytes(str(length)))

        # Content-Range
        if self.range is not None and self.get_status_code() == 206 and "content-range" not in self.headers:
            first_byte, last_byte = self.range
            if isinstance(self.body, Stream):
                length = self.body.get_real_length()
            else:
                length = len(self.body)
            if length is None:
                length = "*"
            elif last_byte is None or last_byte >= length:
                last_byte = length - 1
            elif first_byte is None:
                first_byte = length - last_byte
                last_byte = length - 1

            yield (as_bytes('Content-Range'), as_bytes("bytes %d-%d/%s" % (first_byte, last_byte, length)))

    def generate_body_chunks(self):
        """
        Return a sequence of body chunks, encoded using 'charset'.
        Note that the chunks in the iteration of a Stream, if they
        are str instances, are assumed to be encoded already.
        """
        if self.body is None:
            pass
        elif isinstance(self.body, Stream):
            for chunk in self.body:
                yield self._encode_chunk(chunk)
        else:
            if self.range is None:
                yield self.body # already encoded by set_body().           
            else:
                first_byte, last_byte = self.range
                length = len(self.body)
                if last_byte is None or last_byte >= length:
                    last_byte = length - 1
                if first_byte is None:
                    first_byte = length - last_byte
                yield self.body[first_byte:last_byte + 1]

    def write(self, output, include_status=True, include_body=True):
        """(output:file, include_status:bool=True, include_body:bool=True)

        Write the HTTP response headers and body to 'output'.  This is not
        a complete HTTP response, as it doesn't start with a response
        status line as specified by RFC 2616.  By default, it does start
        with a "Status" header as described by the CGI spec.  It is expected
        that this response is parsed by the web server and turned into a
        complete HTTP response.  If include_body is False, only the headers
        are written to 'output'.  This is used to support HTTP HEAD requests.
        """
        flush_output = not self.get_buffered() and hasattr(output, 'flush')
        if include_status:
            # "Status" header must come first.
            output.write(as_bytes("Status: %03d %s\r\n" % self.status))
        colon = as_bytes(": ")
        end = as_bytes("\r\n")
        for name, value in self.generate_headers():
            output.write(name + colon + value + end)
        output.write(as_bytes("\r\n"))

        if flush_output:
            output.flush()
        if not include_body:
            return
        for chunk in self.generate_body_chunks():
            output.write(chunk)
            if flush_output:
                output.flush()
        if flush_output:
            output.flush()

class Stream (object):
    """
    A wrapper around response data that can be streamed.  The 'iterable'
    argument must support the iteration protocol.
    Beware that exceptions raised while writing the stream will not be
    handled gracefully.

    Instance attributes:
      iterable : any
        an object that supports the iteration protocol.  The items produced
        by the stream must be be unicode_string or byte_string instances.
        If they are byte_string instances, the encoding is assumed to be that of
        the response.
      length: int | None
        the number of bytes that will be produced by the stream, None
        if it is not known.  Used to set the Content-Length header.
      range: None | (int|None, int|None)
    """
    def __init__(self, iterable, length=None):
        self.iterable = iterable
        self.length = length
        self.range = None

    def __iter__(self):
        if self.range is None:
            return iter(self.iterable)
        else:
            return self.iter_range()

    def get_length(self):
        if self.range is None:
            return self.length
        first_byte, last_byte = self.range
        if self.length is None:
            if first_byte is None or last_byte is None:
                raise ValueError("cannot satisfy open-ended range request with stream that has unknown length")
        else:
            if last_byte is None or last_byte >= self.length:
                last_byte = self.length - 1
            if first_byte is None:
                first_byte = self.length - last_byte
        return last_byte - first_byte + 1

    def iter_range(self):
        byte_position = 0
        first_byte, last_byte = self.range
        length = self.length

        if (first_byte is None or last_byte is None) and length is None:
            raise ValueError("cannot satisfy open-ended range request with stream that has unknown length")

        if first_byte is None:
            first_byte = length - last_byte
            last_byte = length - 1

        if first_byte is None or last_byte is None or (length is not None and last_byte >= length):
            last_byte = length - 1

        for chunk in self.iterable:
            if first_byte < byte_position + len(chunk):
                # Case: this chunk contains some amount of the
                #       requested range.
                if first_byte <= byte_position:
                    # Case: this chunk starts in the middle of the
                    #       requested range.
                    chunk_first_byte = 0
                else:
                    # Case: this range starts in the middle of the
                    #       chunk.
                    chunk_first_byte = first_byte - byte_position
            else:
                # Case: no part of this chunk is called for because we
                #       aren't far enough into the stream yet.
                byte_position += len(chunk)
                continue

            if last_byte >= byte_position:
                # Case: this chunk contains some amount of the
                #       requested range.
                if last_byte >= byte_position + len(chunk):
                    # Case: this chunk ends in the middle of the
                    #       requested range.
                    chunk_last_byte = byte_position + len(chunk) - 1
                else:
                    # Case: this range ends in the middle of the
                    #       chunk.
                    chunk_last_byte = last_byte - byte_position
            else:
                # Case: this range starts before this chunk - note
                #       can this case really happen?
                byte_position += len(chunk)
                continue

            yield chunk[chunk_first_byte:chunk_last_byte + 1]

            if last_byte < byte_position + len(chunk):
                # we've consumed all we need to - ditch the rest
                return

            byte_position += len(chunk)

    def get_real_length(self):
        return self.length

    def set_range(self, range):
        self.range = range

