# -*- encoding: utf-8 -*-

import collections
import re
import time
import functools
import json  # json.dumps to put URL in <script>

from configparser import NoOptionError
from datetime import datetime, timedelta
from html import escape
from io import BytesIO as StringIO
from urllib.parse import unquote, urlparse
from xml.etree import ElementTree as ET

from itsdangerous import SignatureExpired, BadSignature
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from werkzeug.http import dump_cookie
from werkzeug.routing import Rule
from werkzeug.utils import redirect
from werkzeug.wrappers import Response
from werkzeug.wsgi import get_current_url

from isso import utils, local
from isso.utils import (http, parse,
                        JSONResponse as JSON, XMLResponse as XML,
                        render_template)
from isso.utils.hash import md5, sha1
from isso.views import requires


# from Django appearently, looks good to me *duck*
__url_re = re.compile(
    r'^'
    r'(https?://)?'
    # domain...
    r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
    r'localhost|'  # localhost...
    r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'  # ...or ip
    r'(?::\d+)?'  # optional port
    r'(?:/?|[/?]\S+)'
    r'$', re.IGNORECASE)


def isurl(text):
    return __url_re.match(text) is not None


def normalize(url):
    if not url.startswith(("http://", "https://")):
        return "http://" + url
    return url


def xhr(func):
    """A decorator to check for CSRF on POST/PUT/DELETE using a <form>
    element and JS to execute automatically (see #40 for a proof-of-concept).

    When an attacker uses a <form> to downvote a comment, the browser *should*
    add a `Content-Type: ...` header with three possible values:

    * application/x-www-form-urlencoded
    * multipart/form-data
    * text/plain

    If the header is not sent or requests `application/json`, the request is
    not forged (XHR is restricted by CORS separately).
    """

    """
    @apiDefine csrf
    @apiHeader {string="application/json"} Content-Type
        The content type must be set to `application/json` to prevent CSRF attacks.
    """

    def dec(self, env, req, *args, **kwargs):

        if req.content_type and not req.content_type.startswith("application/json"):
            raise Forbidden("CSRF")
        return func(self, env, req, *args, **kwargs)

    return dec


class API(object):

    FIELDS = set(['id', 'parent', 'text', 'author', 'website',
                  'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification'])

    # comment fields, that can be submitted
    ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])

    VIEWS = [
        ('fetch', ('GET', '/')),
        ('new', ('POST', '/new')),
        ('count', ('GET', '/count')),
        ('counts', ('POST', '/count')),
        ('feed', ('GET', '/feed')),
        ('latest', ('GET', '/latest')),
        ('view', ('GET', '/id/<int:id>')),
        ('edit', ('PUT', '/id/<int:id>')),
        ('delete', ('DELETE', '/id/<int:id>')),
        ('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
        ('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
        ('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
        ('like', ('POST', '/id/<int:id>/like')),
        ('dislike', ('POST', '/id/<int:id>/dislike')),
        ('demo', ('GET', '/demo')),
        ('preview', ('POST', '/preview')),
        ('login', ('POST', '/login')),
        ('admin', ('GET', '/admin'))
    ]

    def __init__(self, isso, hasher):

        self.isso = isso
        self.hash = hasher.uhash
        self.cache = isso.cache
        self.signal = isso.signal

        self.conf = isso.conf.section("general")
        self.moderated = isso.conf.getboolean("moderation", "enabled")
        # this is similar to the wordpress setting "Comment author must have a previously approved comment"
        try:
            self.approve_if_email_previously_approved = isso.conf.getboolean("moderation", "approve-if-email-previously-approved")
        except NoOptionError:
            self.approve_if_email_previously_approved = False
        try:
            self.trusted_proxies = list(isso.conf.getiter("server", "trusted-proxies"))
        except NoOptionError:
            self.trusted_proxies = []

        # These configuration records can be read out by client
        self.public_conf = {}
        self.public_conf["reply-to-self"] = isso.conf.getboolean("guard", "reply-to-self")
        self.public_conf["require-email"] = isso.conf.getboolean("guard", "require-email")
        self.public_conf["require-author"] = isso.conf.getboolean("guard", "require-author")
        self.public_conf["reply-notifications"] = isso.conf.getboolean("general", "reply-notifications")
        self.public_conf["gravatar"] = isso.conf.getboolean("general", "gravatar")

        if self.public_conf["gravatar"]:
            self.public_conf["avatar"] = False

        self.guard = isso.db.guard
        self.threads = isso.db.threads
        self.comments = isso.db.comments

        for (view, (method, path)) in self.VIEWS:
            isso.urls.add(
                Rule(path, methods=[method], endpoint=getattr(self, view)))

    @classmethod
    def verify(cls, comment):

        if "text" not in comment:
            return False, "text is missing"

        if not isinstance(comment.get("parent"), (int, type(None))):
            return False, "parent must be an integer or null"

        for key in ("text", "author", "website", "email"):
            if not isinstance(comment.get(key), (str, type(None))):
                return False, "%s must be a string or null" % key

        if len(comment["text"].rstrip()) < 3:
            return False, "text is too short (minimum length: 3)"

        if len(comment["text"]) > 65535:
            return False, "text is too long (maximum length: 65535)"

        if len(comment.get("email") or "") > 254:
            return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"

        if comment.get("website"):
            if len(comment["website"]) > 254:
                return False, "arbitrary length limit"
            if not isurl(comment["website"]):
                return False, "Website not Django-conform"

        return True, ""

    # Common definitions for apidoc follow:
    """
    @apiDefine plainParam
    @apiParam {number=0,1} [plain]
        Iff set to `1`, the plain text entered by the user will be returned in the comments’ `text` attribute (instead of the rendered markdown).
    """
    """
    @apiDefine commentResponse

    @apiSuccess {number} id
        The comment’s id (assigned by the server).
    @apiSuccess {number} parent
        Id of the comment this comment is a reply to. `null` if this is a top-level-comment.
    @apiSuccess {number=1,2,4} mode
        The comment’s mode:
        value | explanation
         ---  | ---
         `1`  | accepted: The comment was accepted by the server and is published.
         `2`  | in moderation queue: The comment was accepted by the server but awaits moderation.
         `4`  | deleted, but referenced: The comment was deleted on the server but is still referenced by replies.
    @apiSuccess {string} author
        The comments’s author’s name or `null`.
    @apiSuccess {string} website
        The comment’s author’s website or `null`.
    @apiSuccess {string} hash
        A hash uniquely identifying the comment’s author.
    @apiSuccess {number} created
        UNIX timestamp of the time the comment was created (on the server).
    @apiSuccess {number} modified
        UNIX timestamp of the time the comment was last modified (on the server). `null` if the comment was not yet modified.
    """

    """
    @api {post} /new create new
    @apiGroup Comment
    @apiDescription
        Creates a new comment. The response will set a cookie on the requestor to enable them to later edit the comment.
    @apiUse csrf

    @apiParam {string} uri
        The uri of the thread to create the comment on.
    @apiParam {string} text
        The comment’s raw text.
    @apiParam {string} [author]
        The comment’s author’s name.
    @apiParam {string} [email]
        The comment’s author’s email address.
    @apiParam {string} [website]
        The comment’s author’s website’s url.
    @apiParam {number} [parent]
        The parent comment’s id iff the new comment is a response to an existing comment.

    @apiExample {curl} Create a reply to comment with id 15:
        curl 'https://comments.example.com/new?uri=/thread/' -d '{"text": "Stop saying that! *isso*!", "author": "Max Rant", "email": "rant@example.com", "parent": 15}' -H 'Content-Type: application/json' -c cookie.txt

    @apiUse commentResponse

    @apiSuccessExample Success after the above request:
        {
            "website": null,
            "author": "Max Rant",
            "parent": 15,
            "created": 1464940838.254393,
            "text": "&lt;p&gt;Stop saying that! &lt;em&gt;isso&lt;/em&gt;!&lt;/p&gt;",
            "dislikes": 0,
            "modified": null,
            "mode": 1,
            "hash": "e644f6ee43c0",
            "id": 23,
            "likes": 0
        }
    """
    @xhr
    @requires(str, 'uri')
    def new(self, environ, request, uri):

        data = request.get_json()

        for field in set(data.keys()) - API.ACCEPT:
            data.pop(field)

        for key in ("author", "email", "website", "parent"):
            data.setdefault(key, None)

        valid, reason = API.verify(data)
        if not valid:
            return BadRequest(reason)

        for field in ("author", "email", "website"):
            if data.get(field) is not None:
                data[field] = escape(data[field], quote=False)

        if data.get("website"):
            data["website"] = normalize(data["website"])

        data['mode'] = 2 if self.moderated else 1
        data['remote_addr'] = self._remote_addr(request)

        with self.isso.lock:
            if uri not in self.threads:
                if 'title' not in data:
                    with http.curl('GET', local("origin"), uri) as resp:
                        if resp and resp.status == 200:
                            uri, title = parse.thread(resp.read(), id=uri)
                        else:
                            return NotFound('URI does not exist %s')
                else:
                    title = data['title']

                thread = self.threads.new(uri, title)
                self.signal("comments.new:new-thread", thread)
            else:
                thread = self.threads[uri]

        # notify extensions that the new comment is about to save
        self.signal("comments.new:before-save", thread, data)

        valid, reason = self.guard.validate(uri, data)
        if not valid:
            self.signal("comments.new:guard", reason)
            raise Forbidden(reason)

        with self.isso.lock:
            # if email-based auto-moderation enabled, check for previously approved author
            # right before approval.
            if self.approve_if_email_previously_approved and self.comments.is_previously_approved_author(data['email']):
                data['mode'] = 1

            rv = self.comments.add(uri, data)

        # notify extension, that the new comment has been successfully saved
        self.signal("comments.new:after-save", thread, rv)

        cookie = self.create_cookie(
            value=self.isso.sign([rv["id"], sha1(rv["text"])]),
            max_age=self.conf.getint('max-age'))

        rv["text"] = self.isso.render(rv["text"])
        rv["hash"] = self.hash(rv['email'] or rv['remote_addr'])

        self.cache.set(
            'hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])

        rv = self._add_gravatar_image(rv)

        for key in set(rv.keys()) - API.FIELDS:
            rv.pop(key)

        # success!
        self.signal("comments.new:finish", thread, rv)

        resp = JSON(rv, 202 if rv["mode"] == 2 else 201)
        resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
        resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
        return resp

    def _remote_addr(self, request):
        """Return the anonymized IP address of the requester.

        Takes into consideration a potential X-Forwarded-For HTTP header
        if a necessary server.trusted-proxies configuration entry is set.

        Recipe source: https://stackoverflow.com/a/22936947/636849
        """
        remote_addr = request.remote_addr
        if self.trusted_proxies:
            route = request.access_route + [remote_addr]
            remote_addr = next((addr for addr in reversed(route)
                                if addr not in self.trusted_proxies), remote_addr)
        return utils.anonymize(str(remote_addr))

    def create_cookie(self, **kwargs):
        """
        Setting cookies to SameSite=None requires "Secure" attribute.
        For http-only, we need to override the dump_cookie() default SameSite=None
        or the cookie will be rejected.
        https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#samesitenone_requires_secure
        """
        isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
        samesite = self.isso.conf.get("server", "samesite")
        if isso_host_script.startswith("https://"):
            secure = True
            samesite = samesite or "None"
        else:
            secure = False
            samesite = samesite or "Lax"
        return functools.partial(dump_cookie, **kwargs,
                                 secure=secure, samesite=samesite)

    """
    @api {get} /id/:id view
    @apiGroup Comment
    @apiDescription
        View an existing comment, for the purpose of editing. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details.

    @apiParam {number} id
        The id of the comment to view.
    @apiUse plainParam

    @apiExample {curl} View the comment with id 4:
        curl 'https://comments.example.com/id/4' -b cookie.txt

    @apiUse commentResponse

    @apiSuccessExample Example result:
        {
            "website": null,
            "author": null,
            "parent": null,
            "created": 1464914341.312426,
            "text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
            "dislikes": 0,
            "modified": null,
            "mode": 1,
            "id": 4,
            "likes": 1
        }
    """

    def view(self, environ, request, id):

        rv = self.comments.get(id)
        if rv is None:
            raise NotFound

        try:
            self.isso.unsign(request.cookies.get(str(id), ''))
        except (SignatureExpired, BadSignature):
            raise Forbidden

        for key in set(rv.keys()) - API.FIELDS:
            rv.pop(key)

        if request.args.get('plain', '0') == '0':
            rv['text'] = self.isso.render(rv['text'])

        return JSON(rv, 200)

    """
    @api {put} /id/:id edit
    @apiGroup Comment
    @apiDescription
        Edit an existing comment. Editing a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details. Editing a comment will set a new edit cookie in the response.
    @apiUse csrf

    @apiParam {number} id
        The id of the comment to edit.
    @apiParam {string} text
        A new (raw) text for the comment.
    @apiParam {string} [author]
        The modified comment’s author’s name.
    @apiParam {string} [webiste]
        The modified comment’s author’s website.

    @apiExample {curl} Edit comment with id 23:
        curl -X PUT 'https://comments.example.com/id/23' -d {"text": "I see your point. However, I still disagree.", "website": "maxrant.important.com"} -H 'Content-Type: application/json' -b cookie.txt

    @apiUse commentResponse

    @apiSuccessExample Example response:
        {
            "website": "maxrant.important.com",
            "author": "Max Rant",
            "parent": 15,
            "created": 1464940838.254393,
            "text": "&lt;p&gt;I see your point. However, I still disagree.&lt;/p&gt;",
            "dislikes": 0,
            "modified": 1464943439.073961,
            "mode": 1,
            "id": 23,
            "likes": 0
        }
    """
    @xhr
    def edit(self, environ, request, id):

        try:
            rv = self.isso.unsign(request.cookies.get(str(id), ''))
        except (SignatureExpired, BadSignature):
            raise Forbidden

        if rv[0] != id:
            raise Forbidden

        # verify checksum, mallory might skip cookie deletion when he deletes a comment
        if rv[1] != sha1(self.comments.get(id)["text"]):
            raise Forbidden

        data = request.get_json()

        if "text" not in data or data["text"] is None or len(data["text"]) < 3:
            raise BadRequest("no text given")

        for key in set(data.keys()) - set(["text", "author", "website"]):
            data.pop(key)

        data['modified'] = time.time()

        with self.isso.lock:
            rv = self.comments.update(id, data)

        for key in set(rv.keys()) - API.FIELDS:
            rv.pop(key)

        self.signal("comments.edit", rv)

        cookie = self.create_cookie(
            value=self.isso.sign([rv["id"], sha1(rv["text"])]),
            max_age=self.conf.getint('max-age'))

        rv["text"] = self.isso.render(rv["text"])

        resp = JSON(rv, 200)
        resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
        resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
        return resp

    """
    @api {delete} '/id/:id' delete
    @apiGroup Comment
    @apiDescription
        Delete an existing comment. Deleting a comment is only possible for a short period of time after it was created and only if the requestor has a valid cookie for it. See the [isso server documentation](https://posativ.org/isso/docs/configuration/server) for details.

    @apiParam {number} id
        Id of the comment to delete.

    @apiExample {curl} Delete comment with id 14:
        curl -X DELETE 'https://comments.example.com/id/14' -b cookie.txt

    @apiSuccessExample Successful deletion returns null:
        null
    """
    @xhr
    def delete(self, environ, request, id, key=None):

        try:
            rv = self.isso.unsign(request.cookies.get(str(id), ""))
        except (SignatureExpired, BadSignature):
            raise Forbidden
        else:
            if rv[0] != id:
                raise Forbidden

            # verify checksum, mallory might skip cookie deletion when he deletes a comment
            if rv[1] != sha1(self.comments.get(id)["text"]):
                raise Forbidden

        item = self.comments.get(id)

        if item is None:
            raise NotFound

        self.cache.delete(
            'hash', (item['email'] or item['remote_addr']).encode('utf-8'))

        with self.isso.lock:
            rv = self.comments.delete(id)

        if rv:
            for key in set(rv.keys()) - API.FIELDS:
                rv.pop(key)

        self.signal("comments.delete", id)

        resp = JSON(rv, 200)
        cookie = self.create_cookie(expires=0, max_age=0)

        resp.headers.add("Set-Cookie", cookie(str(id)))
        resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
        return resp

    """
    @api {get} /id/:id/unsubscribe/:email/key unsubscribe
    @apiGroup Comment
    @apiDescription
        Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.

    @apiParam {number} id
        The id of the comment to unsubscribe from replies to.
    @apiParam {string} email
        The email address of the subscriber.
    @apiParam {string} key
        The key to authenticate the subscriber.

    @apiExample {curl} Unsubscribe Alice from replies to comment with id 13:
        curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos'

    @apiSuccessExample {html} Using GET:
        &lt;!DOCTYPE html&gt;
        &lt;html&gt;
            &lt;head&gtSuccessfully unsubscribed&lt;/head&gt;
            &lt;body&gt;
              &lt;p&gt;You have been unsubscribed from replies in the given conversation.&lt;/p&gt;
            &lt;/body&gt;
        &lt;/html&gt;
    """

    def unsubscribe(self, environ, request, id, email, key):
        email = unquote(email)

        try:
            rv = self.isso.unsign(key, max_age=2**32)
        except (BadSignature, SignatureExpired):
            raise Forbidden

        if not isinstance(rv, list) or len(rv) != 2:
            raise Forbidden

        if rv[0] != 'unsubscribe' or rv[1] != email:
            raise Forbidden

        item = self.comments.get(id)

        if item is None:
            raise NotFound

        with self.isso.lock:
            self.comments.unsubscribe(email, id)

        modal = (
            "<!DOCTYPE html>"
            "<html>"
            "<head>"
            "  <title>Successfully unsubscribed</title>"
            "</head>"
            "<body>"
            "  <p>You have been unsubscribed from replies in the given conversation.</p>"
            "</body>"
            "</html>")

        return Response(modal, 200, content_type="text/html")

    """
    @api {post} /id/:id/:action/key moderate
    @apiGroup Comment
    @apiDescription
        Publish or delete a comment that is in the moderation queue (mode `2`). In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.

        This endpoint can also be used with a `GET` request. In that case, a html page is returned that asks the user whether they are sure to perform the selected action. If they select “yes”, the query is repeated using `POST`.

    @apiParam {number} id
        The id of the comment to moderate.
    @apiParam {string=activate,edit,delete} action
        `activate` to publish the comment (change its mode to `1`).
        `delete` to delete the comment
    @apiParam {string} key
        The moderation key to authenticate the moderation.

    @apiExample {curl} delete comment with id 13:
        curl -X POST 'https://comments.example.com/id/13/delete/MTM.CjL6Fg.REIdVXa-whJS_x8ojQL4RrXnuF4'

    @apiSuccessExample {html} Using GET:
        &lt;!DOCTYPE html&gt;
        &lt;html&gt;
            &lt;head&gt;
                &lt;script&gt;
                    if (confirm('Delete: Are you sure?')) {
                        xhr = new XMLHttpRequest;
                        xhr.open('POST', window.location.href);
                        xhr.send(null);
                        xhr.onload = function() {
                            window.location.href = "https://example.com/example-thread/#isso-13";
                        };
                    }
                &lt;/script&gt;

    @apiSuccessExample Using POST:
        Comment has been deleted
    """

    def moderate(self, environ, request, id, action, key):
        try:
            id = self.isso.unsign(key, max_age=2**32)
        except (BadSignature, SignatureExpired):
            raise Forbidden

        item = self.comments.get(id)
        if item is None:
            raise NotFound

        thread = self.threads.get(item['tid'])
        link = local("origin") + thread["uri"] + "#isso-%i" % item["id"]

        if request.method == "GET":
            modal = (
                "<!DOCTYPE html>"
                "<html>"
                "<head>"
                "<script>"
                "  if (confirm('%s: Are you sure?')) {"
                "      xhr = new XMLHttpRequest;"
                "      xhr.open('POST', window.location.href);"
                "      xhr.send(null);"
                "      xhr.onload = function() {"
                "          window.location.href = %s;"
                "      };"
                "  }"
                "</script>" % (action.capitalize(), json.dumps(link)))

            return Response(modal, 200, content_type="text/html")

        if action == "activate":
            if item['mode'] == 1:
                return Response("Already activated", 200)
            with self.isso.lock:
                self.comments.activate(id)
            self.signal("comments.activate", thread, item)
            return Response("Comment has been activated", 200)
        elif action == "edit":
            data = request.get_json()
            with self.isso.lock:
                rv = self.comments.update(id, data)
            for key in set(rv.keys()) - API.FIELDS:
                rv.pop(key)
            self.signal("comments.edit", rv)
            return JSON(rv, 200)
        else:
            with self.isso.lock:
                self.comments.delete(id)
            self.cache.delete(
                'hash', (item['email'] or item['remote_addr']).encode('utf-8'))
            self.signal("comments.delete", id)
            return Response("Comment has been deleted", 200)

        """
        @api {get} / get comments
        @apiGroup Thread
        @apiDescription Queries the comments of a thread.

        @apiParam {string} uri
            The URI of thread to get the comments from.
        @apiParam {number} [parent]
            Return only comments that are children of the comment with the provided ID.
        @apiUse plainParam
        @apiParam {number} [limit]
            The maximum number of returned top-level comments. Omit for unlimited results.
        @apiParam {number} [nested_limit]
            The maximum number of returned nested comments per commint. Omit for unlimited results.
        @apiParam {number} [after]
            Includes only comments were added after the provided UNIX timestamp.

        @apiSuccess {number} total_replies
            The number of replies if the `limit` parameter was not set. If `after` is set to `X`, this is the number of comments that were created after `X`. So setting `after` may change this value!
        @apiSuccess {Object[]} replies
            The list of comments. Each comment also has the `total_replies`, `replies`, `id` and `hidden_replies` properties to represent nested comments.
        @apiSuccess {number} id
            Id of the comment `replies` is the list of replies of. `null` for the list of toplevel comments.
        @apiSuccess {number} hidden_replies
            The number of comments that were ommited from the results because of the `limit` request parameter. Usually, this will be `total_replies` - `limit`.

        @apiExample {curl} Get 2 comments with 5 responses:
            curl 'https://comments.example.com/?uri=/thread/&limit=2&nested_limit=5'
        @apiSuccessExample Example reponse:
            {
              "total_replies": 14,
              "replies": [
                {
                  "website": null,
                  "author": null,
                  "parent": null,
                  "created": 1464818460.732863,
                  "text": "&lt;p&gt;Hello, World!&lt;/p&gt;",
                  "total_replies": 1,
                  "hidden_replies": 0,
                  "dislikes": 2,
                  "modified": null,
                  "mode": 1,
                  "replies": [
                    {
                      "website": null,
                      "author": null,
                      "parent": 1,
                      "created": 1464818460.769638,
                      "text": "&lt;p&gt;Hi, now some Markdown: &lt;em&gt;Italic&lt;/em&gt;, &lt;strong&gt;bold&lt;/strong&gt;, &lt;code&gt;monospace&lt;/code&gt;.&lt;/p&gt;",
                      "dislikes": 0,
                      "modified": null,
                      "mode": 1,
                      "hash": "2af4e1a6c96a",
                      "id": 2,
                      "likes": 2
                    }
                  ],
                  "hash": "1cb6cc0309a2",
                  "id": 1,
                  "likes": 2
                },
                {
                  "website": null,
                  "author": null,
                  "parent": null,
                  "created": 1464818460.80574,
                  "text": "&lt;p&gt;Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium at commodi cum deserunt dolore, error fugiat harum incidunt, ipsa ipsum mollitia nam provident rerum sapiente suscipit tempora vitae? Est, qui?&lt;/p&gt;",
                  "total_replies": 0,
                  "hidden_replies": 0,
                  "dislikes": 0,
                  "modified": null,
                  "mode": 1,
                  "replies": [],
                  "hash": "1cb6cc0309a2",
                  "id": 3,
                  "likes": 0
                },
                "id": null,
                "hidden_replies": 12
            }
        """
    @requires(str, 'uri')
    def fetch(self, environ, request, uri):

        args = {
            'uri': uri,
            'after': request.args.get('after', 0)
        }

        try:
            args['limit'] = int(request.args.get('limit'))
        except TypeError:
            args['limit'] = None
        except ValueError:
            return BadRequest("limit should be integer")

        if request.args.get('parent') is not None:
            try:
                args['parent'] = int(request.args.get('parent'))
                root_id = args['parent']
            except ValueError:
                return BadRequest("parent should be integer")
        else:
            args['parent'] = None
            root_id = None

        plain = request.args.get('plain', '0') == '0'

        reply_counts = self.comments.reply_count(uri, after=args['after'])

        if args['limit'] == 0:
            root_list = []
        else:
            root_list = list(self.comments.fetch(**args))

        if root_id not in reply_counts:
            reply_counts[root_id] = 0

        try:
            nested_limit = int(request.args.get('nested_limit'))
        except TypeError:
            nested_limit = None
        except ValueError:
            return BadRequest("nested_limit should be integer")

        rv = {
            'id': root_id,
            'total_replies': reply_counts[root_id],
            'hidden_replies': reply_counts[root_id] - len(root_list),
            'replies': self._process_fetched_list(root_list, plain),
            'config': self.public_conf
        }
        # We are only checking for one level deep comments
        if root_id is None:
            for comment in rv['replies']:
                if comment['id'] in reply_counts:
                    comment['total_replies'] = reply_counts[comment['id']]
                    if nested_limit is not None:
                        if nested_limit > 0:
                            args['parent'] = comment['id']
                            args['limit'] = nested_limit
                            replies = list(self.comments.fetch(**args))
                        else:
                            replies = []
                    else:
                        args['parent'] = comment['id']
                        replies = list(self.comments.fetch(**args))
                else:
                    comment['total_replies'] = 0
                    replies = []

                comment['hidden_replies'] = comment['total_replies'] - \
                    len(replies)
                comment['replies'] = self._process_fetched_list(replies, plain)

        return JSON(rv, 200)

    def _add_gravatar_image(self, item):
        if not self.conf.getboolean('gravatar'):
            return item

        email = item['email'] or item['author'] or ''
        email_md5_hash = md5(email)

        gravatar_url = self.conf.get('gravatar-url')
        item['gravatar_image'] = gravatar_url.format(email_md5_hash)

        return item

    def _process_fetched_list(self, fetched_list, plain=False):
        for item in fetched_list:

            key = item['email'] or item['remote_addr']
            val = self.cache.get('hash', key.encode('utf-8'))

            if val is None:
                val = self.hash(key)
                self.cache.set('hash', key.encode('utf-8'), val)

            item['hash'] = val

            item = self._add_gravatar_image(item)

            for key in set(item.keys()) - API.FIELDS:
                item.pop(key)

        if plain:
            for item in fetched_list:
                item['text'] = self.isso.render(item['text'])

        return fetched_list

    """
    @apiDefine likeResponse
    @apiSuccess {number} likes
        The (new) number of likes on the comment.
    @apiSuccess {number} dislikes
        The (new) number of dislikes on the comment.
    """

    """
    @api {post} /id/:id/like like
    @apiGroup Comment
    @apiDescription
         Puts a “like” on a comment. The author of a comment cannot like its own comment.

    @apiParam {number} id
        The id of the comment to like.

    @apiExample {curl} Like comment with id 23:
        curl -X POST 'https://comments.example.com/id/23/like'

    @apiUse likeResponse

    @apiSuccessExample Example response
        {
            "likes": 5,
            "dislikes": 2
        }
    """
    @xhr
    def like(self, environ, request, id):

        nv = self.comments.vote(True, id, self._remote_addr(request))
        return JSON(nv, 200)

    """
    @api {post} /id/:id/dislike dislike
    @apiGroup Comment
    @apiDescription
         Puts a “dislike” on a comment. The author of a comment cannot dislike its own comment.

    @apiParam {number} id
        The id of the comment to dislike.

    @apiExample {curl} Dislike comment with id 23:
        curl -X POST 'https://comments.example.com/id/23/dislike'

    @apiUse likeResponse

    @apiSuccessExample Example response
        {
            "likes": 4,
            "dislikes": 3
        }
    """
    @xhr
    def dislike(self, environ, request, id):

        nv = self.comments.vote(False, id, self._remote_addr(request))
        return JSON(nv, 200)

    # TODO: remove someday (replaced by :func:`counts`)
    @requires(str, 'uri')
    def count(self, environ, request, uri):

        rv = self.comments.count(uri)[0]

        if rv == 0:
            raise NotFound

        return JSON(rv, 200)

    """
    @api {post} /count count comments
    @apiGroup Thread
    @apiDescription
        Counts the number of comments on multiple threads. The requestor provides a list of thread uris. The number of comments on each thread is returned as a list, in the same order as the threads were requested. The counts include comments that are reponses to comments.

    @apiExample {curl} get the count of 5 threads:
        curl 'https://comments.example.com/count' -d '["/blog/firstPost.html", "/blog/controversalPost.html", "/blog/howToCode.html",    "/blog/boringPost.html", "/blog/isso.html"]

    @apiSuccessExample Counts of 5 threads:
        [2, 18, 4, 0, 3]
    """
    def counts(self, environ, request):

        data = request.get_json()

        if not isinstance(data, list) and not all(isinstance(x, str) for x in data):
            raise BadRequest("JSON must be a list of URLs")

        return JSON(self.comments.count(*data), 200)

    """
    @api {get} /feed Atom feed for comments
    @apiGroup Thread
    @apiDescription
        Provide an Atom feed for the given thread.
    """
    @requires(str, 'uri')
    def feed(self, environ, request, uri):
        conf = self.isso.conf.section("rss")
        if not conf.get('base'):
            raise NotFound

        args = {
            'uri': uri,
            'order_by': 'id',
            'asc': 0,
            'limit': conf.getint('limit')
        }
        try:
            args['limit'] = max(int(request.args.get('limit')), args['limit'])
        except TypeError:
            pass
        except ValueError:
            return BadRequest("limit should be integer")
        comments = self.comments.fetch(**args)
        base = conf.get('base').rstrip('/')
        hostname = urlparse(base).netloc

        # Let's build an Atom feed.
        #  RFC 4287: https://tools.ietf.org/html/rfc4287
        #  RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
        #  For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
        feed = ET.Element('feed', {
            'xmlns': 'http://www.w3.org/2005/Atom',
            'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
        })

        # For feed ID, we would use thread ID, but we may not have
        # one. Therefore, we use the URI. We don't have a year
        # either...
        id = ET.SubElement(feed, 'id')
        id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
            hostname=hostname, uri=uri)

        # For title, we don't have much either. Be pretty generic.
        title = ET.SubElement(feed, 'title')
        title.text = 'Comments for {hostname}{uri}'.format(
            hostname=hostname, uri=uri)

        comment0 = None

        for comment in comments:
            if comment0 is None:
                comment0 = comment

            entry = ET.SubElement(feed, 'entry')
            # We don't use a real date in ID either to help with
            # threading.
            id = ET.SubElement(entry, 'id')
            id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
                hostname=hostname,
                tid=comment['tid'],
                id=comment['id'])
            title = ET.SubElement(entry, 'title')
            title.text = 'Comment #{}'.format(comment['id'])
            updated = ET.SubElement(entry, 'updated')
            updated.text = '{}Z'.format(datetime.fromtimestamp(
                comment['modified'] or comment['created']).isoformat())
            author = ET.SubElement(entry, 'author')
            name = ET.SubElement(author, 'name')
            name.text = comment['author']
            ET.SubElement(entry, 'link', {
                'href': '{base}{uri}#isso-{id}'.format(
                    base=base,
                    uri=uri, id=comment['id'])
            })
            content = ET.SubElement(entry, 'content', {
                'type': 'html',
            })
            content.text = self.isso.render(comment['text'])

            if comment['parent']:
                ET.SubElement(entry, 'thr:in-reply-to', {
                    'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
                        hostname=hostname,
                        tid=comment['tid'],
                        id=comment['parent']),
                    'href': '{base}{uri}#isso-{id}'.format(
                        base=base,
                        uri=uri, id=comment['parent'])
                })

        # Updated is mandatory. If we have comments, we use the date
        # of last modification of the first one (which is the last
        # one). Otherwise, we use a fixed date.
        updated = ET.Element('updated')
        if comment0 is None:
            updated.text = '1970-01-01T01:00:00Z'
        else:
            updated.text = datetime.fromtimestamp(
                comment0['modified'] or comment0['created']).isoformat()
            updated.text += 'Z'
        feed.insert(0, updated)

        output = StringIO()
        ET.ElementTree(feed).write(output,
                                   encoding='utf-8',
                                   xml_declaration=True)
        response = XML(output.getvalue(), 200)

        # Add an etag/last-modified value for caching purpose
        if comment0 is None:
            response.set_etag('empty')
            response.last_modified = 0
        else:
            response.set_etag('{tid}-{id}'.format(**comment0))
            response.last_modified = comment0['modified'] or comment0['created']
        return response.make_conditional(request)

    def preview(self, environment, request):
        data = request.get_json()

        if "text" not in data or data["text"] is None:
            raise BadRequest("no text given")

        return JSON({'text': self.isso.render(data["text"])}, 200)

    def demo(self, env, req):
        return redirect(
            get_current_url(env, strip_querystring=True) + '/index.html'
        )

    def login(self, env, req):
        if not self.isso.conf.getboolean("admin", "enabled"):
            isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
            return render_template('disabled.html', isso_host_script=isso_host_script)
        data = req.form
        password = self.isso.conf.get("admin", "password")
        if data['password'] and data['password'] == password:
            response = redirect(re.sub(
                r'/login$',
                '/admin',
                get_current_url(env, strip_querystring=True)
            ))
            cookie = self.create_cookie(value=self.isso.sign({"logged": True}),
                                        expires=datetime.now() + timedelta(1))
            response.headers.add("Set-Cookie", cookie("admin-session"))
            response.headers.add("X-Set-Cookie", cookie("isso-admin-session"))
            return response
        else:
            isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
            return render_template('login.html', isso_host_script=isso_host_script)

    def admin(self, env, req):
        isso_host_script = self.isso.conf.get("server", "public-endpoint") or local.host
        if not self.isso.conf.getboolean("admin", "enabled"):
            return render_template('disabled.html', isso_host_script=isso_host_script)
        try:
            data = self.isso.unsign(req.cookies.get('admin-session', ''),
                                    max_age=60 * 60 * 24)
        except BadSignature:
            return render_template('login.html', isso_host_script=isso_host_script)
        if not data or not data['logged']:
            return render_template('login.html', isso_host_script=isso_host_script)
        page_size = 100
        page = int(req.args.get('page', 0))
        order_by = req.args.get('order_by', 'created')
        asc = int(req.args.get('asc', 0))
        mode = int(req.args.get('mode', 2))
        comments = self.comments.fetchall(mode=mode, page=page,
                                          limit=page_size,
                                          order_by=order_by,
                                          asc=asc)
        comments_enriched = []
        for comment in list(comments):
            comment['hash'] = self.isso.sign(comment['id'])
            comments_enriched.append(comment)
        comment_mode_count = self.comments.count_modes()
        max_page = int(sum(comment_mode_count.values()) / 100)
        return render_template('admin.html', comments=comments_enriched,
                               page=int(page), mode=int(mode),
                               conf=self.conf, max_page=max_page,
                               counts=comment_mode_count,
                               order_by=order_by, asc=asc,
                               isso_host_script=isso_host_script)
    """
    @api {get} /latest latest
    @apiGroup Comment
    @apiDescription
        Get the latest comments from the system, no matter which thread

    @apiParam {number} limit
        The quantity of last comments to retrieve

    @apiExample {curl} Get the latest 5 comments
        curl 'https://comments.example.com/latest?limit=5'

    @apiUse commentResponse

    @apiSuccessExample Example result:
        [
            {
                "website": null,
                "uri": "/some",
                "author": null,
                "parent": null,
                "created": 1464912312.123416,
                "text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
                "dislikes": 0,
                "modified": null,
                "mode": 1,
                "id": 3,
                "likes": 1
            },
            {
                "website": null,
                "uri": "/other",
                "author": null,
                "parent": null,
                "created": 1464914341.312426,
                "text": " &lt;p&gt;I want to use MySQL&lt;/p&gt;",
                "dislikes": 0,
                "modified": null,
                "mode": 1,
                "id": 4,
                "likes": 0
            }
        ]
    """

    def latest(self, environ, request):
        # if the feature is not allowed, don't present the endpoint
        if not self.conf.getboolean("latest-enabled"):
            return NotFound()

        # get and check the limit
        bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)"
        try:
            limit = int(request.args['limit'])
        except (KeyError, ValueError):
            return BadRequest(bad_limit_msg)
        if limit <= 0:
            return BadRequest(bad_limit_msg)

        # retrieve the latest N comments from the DB
        all_comments_gen = self.comments.fetchall(limit=None, order_by='created', mode='1')
        comments = collections.deque(all_comments_gen, maxlen=limit)

        # prepare a special set of fields (except text which is rendered specifically)
        fields = {
            'author',
            'created',
            'dislikes',
            'id',
            'likes',
            'mode',
            'modified',
            'parent',
            'text',
            'uri',
            'website',
        }

        # process the retrieved comments and build results
        result = []
        for comment in comments:
            processed = {key: comment[key] for key in fields}
            processed['text'] = self.isso.render(comment['text'])
            result.append(processed)

        return JSON(result, 200)
