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

from __future__ import unicode_literals

import re
import cgi
import time
import functools

from datetime import datetime, timedelta
from itsdangerous import SignatureExpired, BadSignature
from xml.etree import ElementTree as ET

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

from isso.compat import text_type as str

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

try:
    from urlparse import urlparse
except ImportError:
    from urllib.parse import urlparse
try:
    from urllib import unquote
except ImportError:
    from urllib.parse import unquote
try:
    from StringIO import StringIO
except ImportError:
    from io import BytesIO as StringIO

# 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')),
        ('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")

        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] = cgi.escape(data[field])

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

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

        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:
            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 = functools.partial(dump_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

    """
    @api {get} /id/:id view
    @apiGroup Comment

    @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'

    @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

        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 = functools.partial(dump_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
        Delte 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 = functools.partial(dump_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/: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&gt;
                &lt;script&gt;
                    if (confirm('Delete: Are you sure?')) {
                        xhr = new XMLHttpRequest;
                        xhr.open('POST', window.location.href);
                        xhr.send(null);
                    }
                &lt;/script&gt;

    @apiSuccessExample Using POST:
        Yo
    """

    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 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,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);
                    }
                &lt;/script&gt;

    @apiSuccessExample Using POST:
        Yo
    """

    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

        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);"
                "  }"
                "</script>" % action.capitalize())

            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)
            thread = self.threads.get(item['tid'])
            self.signal("comments.activate", thread, item)
            return Response("Yo", 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("Yo", 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 not root_list:
                raise NotFound

        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)
        }
        # 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 ""
        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, utils.anonymize(str(request.remote_addr)))
        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, utils.anonymize(str(request.remote_addr)))
        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"):
            return render_template('disabled.html')
        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 = functools.partial(dump_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')
        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', None)
        asc = int(req.args.get('asc', 1))
        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)
