# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2015 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale.  If not, see <http://www.gnu.org/licenses/>.

"""
Radicale Server module.

This module offers a WSGI application class.

To use this module, you should take a look at the file ``radicale.py`` that
should have been included in this package.

"""

import os
import sys
import pprint
import base64
import socket
import ssl
import wsgiref.simple_server
import re
# Manage Python2/3 different modules
# pylint: disable=F0401,E0611
try:
    from http import client
    from urllib.parse import unquote, urlparse
except ImportError:
    import httplib as client
    from urllib import unquote
    from urlparse import urlparse
# pylint: enable=F0401,E0611

from . import auth, config, ical, log, pathutils, rights, storage, xmlutils


VERSION = "1.1.3"

# Standard "not allowed" response that is returned when an authenticated user
# tries to access information they don't have rights to
NOT_ALLOWED = (client.FORBIDDEN, {}, None)

WELL_KNOWN_RE = re.compile(r"/\.well-known/(carddav|caldav)/?$")


class HTTPServer(wsgiref.simple_server.WSGIServer, object):
    """HTTP server."""
    def __init__(self, address, handler, bind_and_activate=True):
        """Create server."""
        ipv6 = ":" in address[0]

        if ipv6:
            self.address_family = socket.AF_INET6

        # Do not bind and activate, as we might change socket options
        super(HTTPServer, self).__init__(address, handler, False)

        if ipv6:
            # Only allow IPv6 connections to the IPv6 socket
            self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)

        if bind_and_activate:
            self.server_bind()
            self.server_activate()


class HTTPSServer(HTTPServer):
    """HTTPS server."""
    def __init__(self, address, handler):
        """Create server by wrapping HTTP socket in an SSL socket."""
        super(HTTPSServer, self).__init__(address, handler, False)

        # Test if the SSL files can be read
        for name in ("certificate", "key"):
            filename = config.get("server", name)
            try:
                open(filename, "r").close()
            except IOError as exception:
                log.LOGGER.warning(
                    "Error while reading SSL %s %r: %s" % (
                        name, filename, exception))

        ssl_kwargs = dict(
            server_side=True,
            certfile=config.get("server", "certificate"),
            keyfile=config.get("server", "key"),
            ssl_version=getattr(
                ssl, config.get("server", "protocol"), ssl.PROTOCOL_SSLv23))
        # add ciphers argument only if supported (Python 2.7+)
        if sys.version_info >= (2, 7):
            ssl_kwargs["ciphers"] = config.get("server", "ciphers") or None

        self.socket = ssl.wrap_socket(self.socket, **ssl_kwargs)

        self.server_bind()
        self.server_activate()


class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
    """HTTP requests handler."""
    def log_message(self, *args, **kwargs):
        """Disable inner logging management."""

    def address_string(self):
        """Client address, formatted for logging."""
        if config.getboolean("server", "dns_lookup"):
            return \
                wsgiref.simple_server.WSGIRequestHandler.address_string(self)
        else:
            return self.client_address[0]


class Application(object):
    """WSGI application managing collections."""
    def __init__(self):
        """Initialize application."""
        super(Application, self).__init__()
        auth.load()
        storage.load()
        rights.load()
        self.encoding = config.get("encoding", "request")
        if config.getboolean("logging", "full_environment"):
            self.headers_log = lambda environ: environ

    # This method is overriden in __init__ if full_environment is set
    # pylint: disable=E0202
    @staticmethod
    def headers_log(environ):
        """Remove environment variables from the headers for logging."""
        request_environ = dict(environ)
        for shell_variable in os.environ:
            if shell_variable in request_environ:
                del request_environ[shell_variable]
        return request_environ
    # pylint: enable=E0202

    def decode(self, text, environ):
        """Try to magically decode ``text`` according to given ``environ``."""
        # List of charsets to try
        charsets = []

        # First append content charset given in the request
        content_type = environ.get("CONTENT_TYPE")
        if content_type and "charset=" in content_type:
            charsets.append(
                content_type.split("charset=")[1].split(";")[0].strip())
        # Then append default Radicale charset
        charsets.append(self.encoding)
        # Then append various fallbacks
        charsets.append("utf-8")
        charsets.append("iso8859-1")

        # Try to decode
        for charset in charsets:
            try:
                return text.decode(charset)
            except UnicodeDecodeError:
                pass
        raise UnicodeDecodeError

    @staticmethod
    def sanitize_uri(uri):
        """Unquote and make absolute to prevent access to other data."""
        uri = unquote(uri)
        return pathutils.sanitize_path(uri)

    def collect_allowed_items(self, items, user):
        """Get items from request that user is allowed to access."""
        read_last_collection_allowed = None
        write_last_collection_allowed = None
        read_allowed_items = []
        write_allowed_items = []

        for item in items:
            if isinstance(item, ical.Collection):
                if rights.authorized(user, item, "r"):
                    log.LOGGER.debug(
                        "%s has read access to collection %s" %
                        (user or "Anonymous", item.url or "/"))
                    read_last_collection_allowed = True
                    read_allowed_items.append(item)
                else:
                    log.LOGGER.debug(
                        "%s has NO read access to collection %s" %
                        (user or "Anonymous", item.url or "/"))
                    read_last_collection_allowed = False

                if rights.authorized(user, item, "w"):
                    log.LOGGER.debug(
                        "%s has write access to collection %s" %
                        (user or "Anonymous", item.url or "/"))
                    write_last_collection_allowed = True
                    write_allowed_items.append(item)
                else:
                    log.LOGGER.debug(
                        "%s has NO write access to collection %s" %
                        (user or "Anonymous", item.url or "/"))
                    write_last_collection_allowed = False
            else:
                # item is not a collection, it's the child of the last
                # collection we've met in the loop. Only add this item
                # if this last collection was allowed.
                if read_last_collection_allowed:
                    log.LOGGER.debug(
                        "%s has read access to item %s" %
                        (user or "Anonymous", item.name))
                    read_allowed_items.append(item)
                else:
                    log.LOGGER.debug(
                        "%s has NO read access to item %s" %
                        (user or "Anonymous", item.name))

                if write_last_collection_allowed:
                    log.LOGGER.debug(
                        "%s has write access to item %s" %
                        (user or "Anonymous", item.name))
                    write_allowed_items.append(item)
                else:
                    log.LOGGER.debug(
                        "%s has NO write access to item %s" %
                        (user or "Anonymous", item.name))

        return read_allowed_items, write_allowed_items

    def __call__(self, environ, start_response):
        """Manage a request."""
        log.LOGGER.info("%s request at %s received" % (
            environ["REQUEST_METHOD"], environ["PATH_INFO"]))
        headers = pprint.pformat(self.headers_log(environ))
        log.LOGGER.debug("Request headers:\n%s" % headers)

        # Strip base_prefix from request URI
        base_prefix = config.get("server", "base_prefix")
        if environ["PATH_INFO"].startswith(base_prefix):
            environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
        elif config.get("server", "can_skip_base_prefix"):
            log.LOGGER.debug(
                "Prefix already stripped from path: %s", environ["PATH_INFO"])
        else:
            # Request path not starting with base_prefix, not allowed
            log.LOGGER.debug(
                "Path not starting with prefix: %s", environ["PATH_INFO"])
            status, headers, _ = NOT_ALLOWED
            start_response(status, list(headers.items()))
            return []

        # Sanitize request URI
        environ["PATH_INFO"] = self.sanitize_uri(environ["PATH_INFO"])
        log.LOGGER.debug("Sanitized path: %s", environ["PATH_INFO"])

        path = environ["PATH_INFO"]

        # Get function corresponding to method
        function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())

        # Ask authentication backend to check rights
        authorization = environ.get("HTTP_AUTHORIZATION", None)

        if authorization:
            authorization = authorization.lstrip("Basic").strip()
            user, password = self.decode(base64.b64decode(
                authorization.encode("ascii")), environ).split(":", 1)
        else:
            user = environ.get("REMOTE_USER")
            password = None

        well_known = WELL_KNOWN_RE.match(path)
        if well_known:
            redirect = config.get("well-known", well_known.group(1))
            try:
                redirect = redirect % ({"user": user} if user else {})
            except KeyError:
                status = client.UNAUTHORIZED
                headers = {
                    "WWW-Authenticate":
                    "Basic realm=\"%s\"" % config.get("server", "realm")}
                log.LOGGER.info(
                    "Refused /.well-known/ redirection to anonymous user")
            else:
                status = client.SEE_OTHER
                log.LOGGER.info("/.well-known/ redirection to: %s" % redirect)
                if sys.version_info < (3, 0):
                    redirect = redirect.encode(self.encoding)
                headers = {"Location": redirect}
            status = "%i %s" % (
                status, client.responses.get(status, "Unknown"))
            start_response(status, list(headers.items()))
            return []

        is_authenticated = auth.is_authenticated(user, password)
        is_valid_user = is_authenticated or not user

        if is_valid_user:
            items = ical.Collection.from_path(
                path, environ.get("HTTP_DEPTH", "0"))
            read_allowed_items, write_allowed_items = \
                self.collect_allowed_items(items, user)
        else:
            read_allowed_items, write_allowed_items = None, None

        # Get content
        content_length = int(environ.get("CONTENT_LENGTH") or 0)
        if content_length:
            content = self.decode(
                environ["wsgi.input"].read(content_length), environ)
            log.LOGGER.debug("Request content:\n%s" % content)
        else:
            content = None

        if is_valid_user and (
                (read_allowed_items or write_allowed_items) or
                (is_authenticated and function == self.do_PROPFIND) or
                function == self.do_OPTIONS):
            status, headers, answer = function(
                environ, read_allowed_items, write_allowed_items, content,
                user)
        else:
            status, headers, answer = NOT_ALLOWED

        if ((status, headers, answer) == NOT_ALLOWED and
                not auth.is_authenticated(user, password) and
                config.get("auth", "type") != "None"):
            # Unknown or unauthorized user
            log.LOGGER.info("%s refused" % (user or "Anonymous user"))
            status = client.UNAUTHORIZED
            headers = {
                "WWW-Authenticate":
                "Basic realm=\"%s\"" % config.get("server", "realm")}
            answer = None

        # Set content length
        if answer:
            log.LOGGER.debug(
                "Response content:\n%s" % self.decode(answer, environ))
            headers["Content-Length"] = str(len(answer))

        if config.has_section("headers"):
            for key in config.options("headers"):
                headers[key] = config.get("headers", key)

        # Start response
        status = "%i %s" % (status, client.responses.get(status, "Unknown"))
        log.LOGGER.debug("Answer status: %s" % status)
        start_response(status, list(headers.items()))

        # Return response content
        return [answer] if answer else []

    # All these functions must have the same parameters, some are useless
    # pylint: disable=W0612,W0613,R0201

    def do_DELETE(self, environ, read_collections, write_collections, content,
                  user):
        """Manage DELETE request."""
        if not len(write_collections):
            return NOT_ALLOWED

        collection = write_collections[0]

        if collection.path == environ["PATH_INFO"].strip("/"):
            # Path matching the collection, the collection must be deleted
            item = collection
        else:
            # Try to get an item matching the path
            name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
            item = collection.items.get(name)

        if item:
            # Evolution bug workaround
            if_match = environ.get("HTTP_IF_MATCH", "*").replace("\\", "")
            if if_match in ("*", item.etag):
                # No ETag precondition or precondition verified, delete item
                answer = xmlutils.delete(environ["PATH_INFO"], collection)
                return client.OK, {}, answer

        # No item or ETag precondition not verified, do not delete item
        return client.PRECONDITION_FAILED, {}, None

    def do_GET(self, environ, read_collections, write_collections, content,
               user):
        """Manage GET request.

        In Radicale, GET requests create collections when the URL is not
        available. This is useful for clients with no MKCOL or MKCALENDAR
        support.

        """
        # Display a "Radicale works!" message if the root URL is requested
        if environ["PATH_INFO"] == "/":
            headers = {"Content-type": "text/html"}
            answer = b"<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
            return client.OK, headers, answer

        if not len(read_collections):
            return NOT_ALLOWED

        collection = read_collections[0]

        item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)

        if item_name:
            # Get collection item
            item = collection.items.get(item_name)
            if item:
                items = [item]
                if collection.resource_type == "calendar":
                    items.extend(collection.timezones)
                answer_text = ical.serialize(
                    collection.tag, collection.headers, items)
                etag = item.etag
            else:
                return client.NOT_FOUND, {}, None
        else:
            # Create the collection if it does not exist
            if not collection.exists:
                if collection in write_collections:
                    log.LOGGER.debug(
                        "Creating collection %s" % collection.name)
                    collection.write()
                else:
                    log.LOGGER.debug(
                        "Collection %s not available and could not be created "
                        "due to missing write rights" % collection.name)
                    return NOT_ALLOWED

            # Get whole collection
            answer_text = collection.text
            etag = collection.etag

        headers = {
            "Content-Type": collection.mimetype,
            "Last-Modified": collection.last_modified,
            "ETag": etag}
        answer = answer_text.encode(self.encoding)
        return client.OK, headers, answer

    def do_HEAD(self, environ, read_collections, write_collections, content,
                user):
        """Manage HEAD request."""
        status, headers, answer = self.do_GET(
            environ, read_collections, write_collections, content, user)
        return status, headers, None

    def do_MKCALENDAR(self, environ, read_collections, write_collections,
                      content, user):
        """Manage MKCALENDAR request."""
        if not len(write_collections):
            return NOT_ALLOWED

        collection = write_collections[0]

        props = xmlutils.props_from_request(content)
        timezone = props.get("C:calendar-timezone")
        if timezone:
            collection.replace("", timezone)
            del props["C:calendar-timezone"]
        with collection.props as collection_props:
            for key, value in props.items():
                collection_props[key] = value
            collection.write()
        return client.CREATED, {}, None

    def do_MKCOL(self, environ, read_collections, write_collections, content,
                 user):
        """Manage MKCOL request."""
        if not len(write_collections):
            return NOT_ALLOWED

        collection = write_collections[0]

        props = xmlutils.props_from_request(content)
        with collection.props as collection_props:
            for key, value in props.items():
                collection_props[key] = value
        collection.write()
        return client.CREATED, {}, None

    def do_MOVE(self, environ, read_collections, write_collections, content,
                user):
        """Manage MOVE request."""
        if not len(write_collections):
            return NOT_ALLOWED

        from_collection = write_collections[0]

        from_name = xmlutils.name_from_path(
            environ["PATH_INFO"], from_collection)
        if from_name:
            item = from_collection.items.get(from_name)
            if item:
                # Move the item
                to_url_parts = urlparse(environ["HTTP_DESTINATION"])
                if to_url_parts.netloc == environ["HTTP_HOST"]:
                    to_url = to_url_parts.path
                    to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
                    to_collection = ical.Collection.from_path(
                        to_path, depth="0")[0]
                    if to_collection in write_collections:
                        to_collection.append(to_name, item.text)
                        from_collection.remove(from_name)
                        return client.CREATED, {}, None
                    else:
                        return NOT_ALLOWED
                else:
                    # Remote destination server, not supported
                    return client.BAD_GATEWAY, {}, None
            else:
                # No item found
                return client.GONE, {}, None
        else:
            # Moving collections, not supported
            return client.FORBIDDEN, {}, None

    def do_OPTIONS(self, environ, read_collections, write_collections,
                   content, user):
        """Manage OPTIONS request."""
        headers = {
            "Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
                      "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
        return client.OK, headers, None

    def do_PROPFIND(self, environ, read_collections, write_collections,
                    content, user):
        """Manage PROPFIND request."""
        # Rights is handled by collection in xmlutils.propfind
        headers = {
            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
            "Content-Type": "text/xml"}
        collections = set(read_collections + write_collections)
        answer = xmlutils.propfind(
            environ["PATH_INFO"], content, collections, user)
        return client.MULTI_STATUS, headers, answer

    def do_PROPPATCH(self, environ, read_collections, write_collections,
                     content, user):
        """Manage PROPPATCH request."""
        if not len(write_collections):
            return NOT_ALLOWED

        collection = write_collections[0]

        answer = xmlutils.proppatch(
            environ["PATH_INFO"], content, collection)
        headers = {
            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
            "Content-Type": "text/xml"}
        return client.MULTI_STATUS, headers, answer

    def do_PUT(self, environ, read_collections, write_collections, content,
               user):
        """Manage PUT request."""
        if not len(write_collections):
            return NOT_ALLOWED

        collection = write_collections[0]

        collection.set_mimetype(environ.get("CONTENT_TYPE"))
        headers = {}
        item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
        item = collection.items.get(item_name)

        # Evolution bug workaround
        etag = environ.get("HTTP_IF_MATCH", "").replace("\\", "")
        match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
        if (not item and not etag) or (
                item and ((etag or item.etag) == item.etag) and not match):
            # PUT allowed in 3 cases
            # Case 1: No item and no ETag precondition: Add new item
            # Case 2: Item and ETag precondition verified: Modify item
            # Case 3: Item and no Etag precondition: Force modifying item
            xmlutils.put(environ["PATH_INFO"], content, collection)
            status = client.CREATED
            # Try to return the etag in the header.
            # If the added item doesn't have the same name as the one given
            # by the client, then there's no obvious way to generate an
            # etag, we can safely ignore it.
            new_item = collection.items.get(item_name)
            if new_item:
                headers["ETag"] = new_item.etag
        else:
            # PUT rejected in all other cases
            status = client.PRECONDITION_FAILED
        return status, headers, None

    def do_REPORT(self, environ, read_collections, write_collections, content,
                  user):
        """Manage REPORT request."""
        if not len(read_collections):
            return NOT_ALLOWED

        collection = read_collections[0]

        headers = {"Content-Type": "text/xml"}

        answer = xmlutils.report(environ["PATH_INFO"], content, collection)
        return client.MULTI_STATUS, headers, answer

    # pylint: enable=W0612,W0613,R0201
