# This file is part of PRAW.
#
# PRAW 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.
#
# PRAW 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
# PRAW.  If not, see <http://www.gnu.org/licenses/>.

"""
Contains code about objects such as Submissions, Redditors or Commments.

There are two main groups of objects in this file. The first are objects that
correspond to a Thing or part of a Thing as specified in reddit's API overview,
https://github.com/reddit/reddit/wiki/API. The second gives functionality that
extends over multiple Things. An object that extends from Saveable indicates
that it can be saved and unsaved in the context of a logged in user.
"""

from __future__ import print_function, unicode_literals
import six
from six.moves.urllib.parse import (  # pylint: disable=F0401
    parse_qs, urlparse, urlunparse)
from heapq import heappop, heappush
from json import dumps
from requests.compat import urljoin
from warnings import warn, warn_explicit
from . import (AuthenticatedReddit as AR, ModConfigMixin as MCMix,
               ModFlairMixin as MFMix, ModLogMixin as MLMix,
               ModOnlyMixin as MOMix, ModSelfMixin as MSMix,
               MultiredditMixin as MultiMix, PrivateMessagesMixin as PMMix,
               SubmitMixin, SubscribeMixin, UnauthenticatedReddit as UR)
from .decorators import (alias_function, limit_chars, restrict_access,
                         deprecated)
from .errors import ClientException
from .internal import (_get_redditor_listing, _get_sorter,
                       _modify_relationship)


REDDITOR_KEYS = ('approved_by', 'author', 'banned_by', 'redditor',
                 'revision_by')


class RedditContentObject(object):
    """Base class that represents actual reddit objects."""

    @classmethod
    def from_api_response(cls, reddit_session, json_dict):
        """Return an instance of the appropriate class from the json_dict."""
        return cls(reddit_session, json_dict=json_dict)

    def __init__(self, reddit_session, json_dict=None, fetch=True,
                 info_url=None, underscore_names=None, uniq=None):
        """Create a new object from the dict of attributes returned by the API.

        The fetch parameter specifies whether to retrieve the object's
        information from the API (only matters when it isn't provided using
        json_dict).

        """
        self._info_url = info_url or reddit_session.config['info']
        self.reddit_session = reddit_session
        self._underscore_names = underscore_names
        self._uniq = uniq
        self._has_fetched = self._populate(json_dict, fetch)

    def __eq__(self, other):
        """Return whether the other instance equals the current."""
        return (isinstance(other, RedditContentObject) and
                self.fullname == other.fullname)

    def __hash__(self):
        """Return the hash of the current instance."""
        return hash(self.fullname)

    def __getattr__(self, attr):
        """Return the value of the `attr` attribute."""
        # Because this method may perform web requests, there are certain
        # attributes we must blacklist to prevent accidental requests:
        # __members__, __methods__: Caused by `dir(obj)` in Python 2.
        # __setstate__: Caused by Pickle deserialization.
        blacklist = ('__members__', '__methods__', '__setstate__')
        if attr not in blacklist and not self._has_fetched:
            self._has_fetched = self._populate(None, True)
            return getattr(self, attr)
        msg = '\'{0}\' has no attribute \'{1}\''.format(type(self), attr)
        raise AttributeError(msg)

    def __getstate__(self):
        """Needed for `pickle`.

        Without this, pickle protocol version 0 will make HTTP requests
        upon serialization, hence slowing it down significantly.
        """
        return self.__dict__

    def __ne__(self, other):
        """Return whether the other instance differs from the current."""
        return not self == other

    def __reduce_ex__(self, _):
        """Needed for `pickle`.

        Without this, `pickle` protocol version 2 will make HTTP requests
        upon serialization, hence slowing it down significantly.
        """
        return self.__reduce__()

    def __setattr__(self, name, value):
        """Set the `name` attribute to `value."""
        if value and name == 'subreddit' and isinstance(value, six.string_types):
            value = Subreddit(self.reddit_session, value, fetch=False)
        elif name == 'permalink' and isinstance(self, Comment):
            # The Reddit API now returns the permalink field for comments. This
            # will unfortunately break PRAW because permalink is a @property on the
            # Comment object. I need to investigate if the property can be removed,
            # for now this is a quick hack to get things working again.
            #     https://github.com/michael-lazar/rtv/issues/462
            return
        elif value and name in REDDITOR_KEYS:
            if isinstance(value, bool):
                pass
            elif isinstance(value, dict):
                value = Redditor(self.reddit_session, json_dict=value['data'])
            elif not value or value == '[deleted]':
                value = None
            else:
                value = Redditor(self.reddit_session, value, fetch=False)
        object.__setattr__(self, name, value)

    def __str__(self):
        """Return a string representation of the RedditContentObject."""
        retval = self.__unicode__()
        if not six.PY3:
            retval = retval.encode('utf-8')
        return retval

    def _get_json_dict(self):
        # (disabled for entire function) pylint: disable=W0212

        # OAuth handling needs to be special cased here. For instance, the user
        # might be calling a method on a Subreddit object that requires first
        # loading the information about the subreddit. This method should try
        # to obtain the information in a scope-less manner unless either:
        # a) The object is a WikiPage and the reddit_session has the `wikiread`
        #    scope.
        # b) The object is not a WikiPage and the reddit_session has the
        #    `read` scope.
        prev_use_oauth = self.reddit_session._use_oauth

        wiki_page = isinstance(self, WikiPage)
        scope = self.reddit_session.has_scope

        self.reddit_session._use_oauth = wiki_page and scope('wikiread') or \
            not wiki_page and scope('read')

        try:
            params = {'uniq': self._uniq} if self._uniq else {}
            response = self.reddit_session.request_json(
                self._info_url, params=params, as_objects=False)
        finally:
            self.reddit_session._use_oauth = prev_use_oauth
        return response['data']

    def _populate(self, json_dict, fetch):
        if json_dict is None:
            json_dict = self._get_json_dict() if fetch else {}

        if self.reddit_session.config.store_json_result is True:
            self.json_dict = json_dict
        else:
            self.json_dict = None

        # TODO: Remove this wikipagelisting hack
        if isinstance(json_dict, list):
            json_dict = {'_tmp': json_dict}

        for name, value in six.iteritems(json_dict):
            if self._underscore_names and name in self._underscore_names:
                name = '_' + name
            setattr(self, name, value)

        self._post_populate(fetch)
        return bool(json_dict) or fetch

    def _post_populate(self, fetch):
        """Called after populating the attributes of the instance."""

    @property
    def fullname(self):
        """Return the object's fullname.

        A fullname is an object's kind mapping like `t3` followed by an
        underscore and the object's base36 id, e.g., `t1_c5s96e0`.

        """
        by_object = self.reddit_session.config.by_object
        return '{0}_{1}'.format(by_object[self.__class__], self.id)

    @property
    @deprecated('``has_fetched`` will not be a public attribute in PRAW4.')
    def has_fetched(self):
        """Return whether the object has been fully fetched from reddit."""
        return self._has_fetched


class Moderatable(RedditContentObject):
    """Interface for Reddit content objects that have can be moderated."""

    @restrict_access(scope='modposts')
    def approve(self):
        """Approve object.

        This reverts a removal, resets the report counter, marks it with a
        green check mark (only visible to other moderators) on the website view
        and sets the approved_by attribute to the logged in user.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['approve']
        data = {'id': self.fullname}
        response = self.reddit_session.request_json(url, data=data)
        urls = [self.reddit_session.config[x] for x in ['modqueue', 'spam']]
        if isinstance(self, Submission):
            urls += self.subreddit._listing_urls  # pylint: disable=W0212
        self.reddit_session.evict(urls)
        return response

    @restrict_access(scope='modposts')
    def distinguish(self, as_made_by='mod', sticky=False):
        """Distinguish object as made by mod, admin or special.

        Distinguished objects have a different author color. With Reddit
        Enhancement Suite it is the background color that changes.

        `sticky` argument only used for top-level Comments.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['distinguish']
        data = {'id': self.fullname,
                'how': 'yes' if as_made_by == 'mod' else as_made_by}
        if isinstance(self, Comment) and self.is_root:
            data['sticky'] = sticky
        return self.reddit_session.request_json(url, data=data)

    @restrict_access(scope='modposts')
    def ignore_reports(self):
        """Ignore future reports on this object.

        This prevents future reports from causing notifications or appearing
        in the various moderation listing. The report count will still
        increment.

        """
        url = self.reddit_session.config['ignore_reports']
        data = {'id': self.fullname}
        return self.reddit_session.request_json(url, data=data)

    @restrict_access(scope='modposts')
    def remove(self, spam=False):
        """Remove object. This is the moderator version of delete.

        The object is removed from the subreddit listings and placed into the
        spam listing. If spam is set to True, then the automatic spam filter
        will try to remove objects with similar attributes in the future.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['remove']
        data = {'id': self.fullname,
                'spam': 'True' if spam else 'False'}
        response = self.reddit_session.request_json(url, data=data)
        urls = [self.reddit_session.config[x] for x in ['modqueue', 'spam']]
        if isinstance(self, Submission) and hasattr(self, 'subreddit'):
            urls += self.subreddit._listing_urls  # pylint: disable=W0212
        self.reddit_session.evict(urls)
        return response

    def undistinguish(self):
        """Remove mod, admin or special distinguishing on object.

        :returns: The json response from the server.

        """
        return self.distinguish(as_made_by='no')

    @restrict_access(scope='modposts')
    def unignore_reports(self):
        """Remove ignoring of future reports on this object.

        Undoes 'ignore_reports'. Future reports will now cause notifications
        and appear in the various moderation listings.

        """
        url = self.reddit_session.config['unignore_reports']
        data = {'id': self.fullname}
        return self.reddit_session.request_json(url, data=data)


class Editable(RedditContentObject):
    """Interface for Reddit content objects that can be edited and deleted."""

    @restrict_access(scope='edit')
    def delete(self):
        """Delete this object.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['del']
        data = {'id': self.fullname}
        response = self.reddit_session.request_json(url, data=data)
        self.reddit_session.evict(self.reddit_session.config['user'])
        return response

    @restrict_access(scope='edit')
    def edit(self, text):
        """Replace the body of the object with `text`.

        :returns: The updated object.

        """
        url = self.reddit_session.config['edit']
        data = {'thing_id': self.fullname,
                'text': text}
        response = self.reddit_session.request_json(url, data=data)
        self.reddit_session.evict(self.reddit_session.config['user'])
        return response['data']['things'][0]


class Gildable(RedditContentObject):
    """Interface for RedditContentObjects that can be gilded."""

    @restrict_access(scope='creddits', oauth_only=True)
    def gild(self, months=None):
        """Gild the Redditor or author of the content.

        :param months: Specifies the number of months to gild. This parameter
            is Only valid when the instance called upon is of type
            Redditor. When not provided, the value defaults to 1.
        :returns: True on success, otherwise raises an exception.

        """
        if isinstance(self, Redditor):
            months = int(months) if months is not None else 1
            if months < 1:
                raise TypeError('months must be at least 1')
            if months > 36:
                raise TypeError('months must be no more than 36')
            response = self.reddit_session.request(
                self.reddit_session.config['gild_user'].format(
                    username=six.text_type(self)), data={'months': months})
        elif months is not None:
            raise TypeError('months is not a valid parameter for {0}'
                            .format(type(self)))
        else:
            response = self.reddit_session.request(
                self.reddit_session.config['gild_thing']
                .format(fullname=self.fullname), data=True)
        return response.status_code == 200


class Hideable(RedditContentObject):
    """Interface for objects that can be hidden."""

    def hide(self, _unhide=False):
        """Hide object in the context of the logged in user.

        :param _unhide: If True, unhide the item instead.  Use
            :meth:`~praw.objects.Hideable.unhide` instead of setting this
            manually.

        :returns: The json response from the server.

        """
        return self.reddit_session.hide(self.fullname, _unhide=_unhide)

    def unhide(self):
        """Unhide object in the context of the logged in user.

        :returns: The json response from the server.

        """
        return self.hide(_unhide=True)


class Inboxable(RedditContentObject):
    """Interface for objects that appear in the inbox (orangereds)."""

    def mark_as_read(self):
        """Mark object as read.

        :returns: The json response from the server.

        """
        return self.reddit_session._mark_as_read([self.fullname])

    def mark_as_unread(self):
        """Mark object as unread.

        :returns: The json response from the server.

        """
        return self.reddit_session._mark_as_read([self.fullname], unread=True)

    def reply(self, text):
        """Reply to object with the specified text.

        :returns: A Comment object for the newly created comment (reply).

        """
        # pylint: disable=W0212
        response = self.reddit_session._add_comment(self.fullname, text)
        # pylint: enable=W0212
        urls = [self.reddit_session.config['inbox']]
        if isinstance(self, Comment):
            urls.append(self.submission._api_link)  # pylint: disable=W0212
        elif isinstance(self, Message):
            urls.append(self.reddit_session.config['sent'])
        self.reddit_session.evict(urls)
        return response


class Messageable(RedditContentObject):
    """Interface for RedditContentObjects that can be messaged."""

    _methods = (('send_message', PMMix),)


class Refreshable(RedditContentObject):
    """Interface for objects that can be refreshed."""

    def refresh(self):
        """Re-query to update object with latest values. Return the object.

        Any listing, such as the submissions on a subreddits top page, will
        automatically be refreshed serverside. Refreshing a submission will
        also refresh all its comments.

        In the rare case of a comment being deleted or removed when it had
        no replies, a second request will be made, not all information will
        be updated and a warning will list the attributes that could not be
        retrieved if there were any.

        """
        unique = self.reddit_session._unique_count  # pylint: disable=W0212
        self.reddit_session._unique_count += 1  # pylint: disable=W0212

        if isinstance(self, Redditor):
            other = Redditor(self.reddit_session, self._case_name, fetch=True,
                             uniq=unique)
        elif isinstance(self, Comment):
            sub = Submission.from_url(self.reddit_session, self.permalink,
                                      params={'uniq': unique})
            if sub.comments:
                other = sub.comments[0]
            else:
                # comment is "specially deleted", a reddit inconsistency;
                # see #519, #524, #535, #537, and #552 it needs to be
                # retreived via /api/info, but that's okay since these
                # specially deleted comments always have the same json
                # structure. The unique count needs to be updated
                # in case the comment originally came from /api/info
                msg = ("Comment {0} was deleted or removed, and had "
                       "no replies when such happened, so a second "
                       "request was made to /api/info.".format(self.name))
                unique = self.reddit_session._unique_count
                self.reddit_session._unique_count += 1
                other = self.reddit_session.get_info(thing_id=self.name,
                                                     params={'uniq': unique})
                oldkeys = set(self.__dict__.keys())
                newkeys = set(other.__dict__.keys())
                keydiff = ", ".join(oldkeys - newkeys)
                if keydiff:
                    msg += "\nCould not retrieve:\n{0}".format(keydiff)
                self.__dict__.update(other.__dict__)  # pylint: disable=W0201
                warn(msg, RuntimeWarning)
                return self
        elif isinstance(self, Multireddit):
            other = Multireddit(self.reddit_session, author=self._author,
                                name=self.name, uniq=unique, fetch=True)
        elif isinstance(self, Submission):
            params = self._params.copy()
            params['uniq'] = unique
            other = Submission.from_url(self.reddit_session, self.permalink,
                                        comment_sort=self._comment_sort,
                                        params=params)
        elif isinstance(self, Subreddit):
            other = Subreddit(self.reddit_session, self._case_name, fetch=True,
                              uniq=unique)
        elif isinstance(self, WikiPage):
            other = WikiPage(self.reddit_session,
                             six.text_type(self.subreddit), self.page,
                             fetch=True, uniq=unique)

        self.__dict__ = other.__dict__  # pylint: disable=W0201
        return self


class Reportable(RedditContentObject):
    """Interface for RedditContentObjects that can be reported."""

    @restrict_access(scope='report')
    def report(self, reason=None):
        """Report this object to the moderators.

        :param reason: The user-supplied reason for reporting a comment
            or submission. Default: None (blank reason)
        :returns: The json response from the server.

        """
        url = self.reddit_session.config['report']
        data = {'id': self.fullname}
        if reason:
            data['reason'] = reason
        response = self.reddit_session.request_json(url, data=data)
        # Reported objects are automatically hidden as well
        # pylint: disable=W0212
        self.reddit_session.evict(
            [self.reddit_session.config['user'],
             urljoin(self.reddit_session.user._url, 'hidden')])
        # pylint: enable=W0212
        return response


class Saveable(RedditContentObject):
    """Interface for RedditContentObjects that can be saved."""

    @restrict_access(scope='save')
    def save(self, unsave=False):
        """Save the object.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['unsave' if unsave else 'save']
        data = {'id': self.fullname,
                'executed': 'unsaved' if unsave else 'saved'}
        response = self.reddit_session.request_json(url, data=data)
        self.reddit_session.evict(self.reddit_session.config['saved'])
        return response

    def unsave(self):
        """Unsave the object.

        :returns: The json response from the server.

        """
        return self.save(unsave=True)


class Voteable(RedditContentObject):
    """Interface for RedditContentObjects that can be voted on."""

    def clear_vote(self):
        """Remove the logged in user's vote on the object.

        Running this on an object with no existing vote has no adverse effects.

        Note: votes must be cast by humans. That is, API clients proxying a
        human's action one-for-one are OK, but bots deciding how to vote on
        content or amplifying a human's vote are not. See the reddit rules for
        more details on what constitutes vote cheating.

        Source for note: http://www.reddit.com/dev/api#POST_api_vote

        :returns: The json response from the server.

        """
        return self.vote()

    def downvote(self):
        """Downvote object. If there already is a vote, replace it.

        Note: votes must be cast by humans. That is, API clients proxying a
        human's action one-for-one are OK, but bots deciding how to vote on
        content or amplifying a human's vote are not. See the reddit rules for
        more details on what constitutes vote cheating.

        Source for note: http://www.reddit.com/dev/api#POST_api_vote

        :returns: The json response from the server.

        """
        return self.vote(direction=-1)

    def upvote(self):
        """Upvote object. If there already is a vote, replace it.

        Note: votes must be cast by humans. That is, API clients proxying a
        human's action one-for-one are OK, but bots deciding how to vote on
        content or amplifying a human's vote are not. See the reddit rules for
        more details on what constitutes vote cheating.

        Source for note: http://www.reddit.com/dev/api#POST_api_vote

        :returns: The json response from the server.

        """
        return self.vote(direction=1)

    @restrict_access(scope='vote')
    def vote(self, direction=0):
        """Vote for the given item in the direction specified.

        Note: votes must be cast by humans. That is, API clients proxying a
        human's action one-for-one are OK, but bots deciding how to vote on
        content or amplifying a human's vote are not. See the reddit rules for
        more details on what constitutes vote cheating.

        Source for note: http://www.reddit.com/dev/api#POST_api_vote

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['vote']
        data = {'id': self.fullname,
                'dir': six.text_type(direction)}
        if self.reddit_session.user:
            # pylint: disable=W0212
            urls = [urljoin(self.reddit_session.user._url, 'disliked'),
                    urljoin(self.reddit_session.user._url, 'liked')]
            # pylint: enable=W0212
            self.reddit_session.evict(urls)
        return self.reddit_session.request_json(url, data=data)


class Comment(Editable, Gildable, Inboxable, Moderatable, Refreshable,
              Reportable, Saveable, Voteable):
    """A class that represents a reddit comments."""

    def __init__(self, reddit_session, json_dict):
        """Construct an instance of the Comment object."""
        super(Comment, self).__init__(reddit_session, json_dict,
                                      underscore_names=['replies'])
        self._has_fetched_replies = not hasattr(self, 'was_comment')
        if self._replies:
            self._replies = self._replies['data']['children']
        elif self._replies == '':  # Comment tree was built and there are none
            self._replies = []
        else:
            self._replies = None
        self._submission = None

    @limit_chars
    def __unicode__(self):
        """Return a string representation of the comment."""
        return getattr(self, 'body', '[Unloaded Comment]')

    @property
    def _fast_permalink(self):
        """Return the short permalink to the comment."""
        if hasattr(self, 'link_id'):  # from /r or /u comments page
            sid = self.link_id.split('_')[1]
        else:  # from user's /message page
            sid = self.context.split('/')[4]
        return urljoin(self.reddit_session.config['comments'], '{0}/_/{1}'
                       .format(sid, self.id))

    def _update_submission(self, submission):
        """Submission isn't set on __init__ thus we need to update it."""
        submission._comments_by_id[self.name] = self  # pylint: disable=W0212
        self._submission = submission
        if self._replies:
            for reply in self._replies:
                reply._update_submission(submission)  # pylint: disable=W0212

    @property
    def is_root(self):
        """Return True when the comment is a top level comment."""
        sub_prefix = self.reddit_session.config.by_object[Submission]
        return self.parent_id.startswith(sub_prefix)

    @property
    def permalink(self):
        """Return a permalink to the comment."""
        return urljoin(self.submission.permalink, self.id)

    @property
    def replies(self):
        """Return a list of the comment replies to this comment.

        If the comment is not from a submission, :meth:`replies` will
        always be an empty list unless you call :meth:`refresh()
        before calling :meth:`replies` due to a limitation in
        reddit's API.

        """
        if self._replies is None or not self._has_fetched_replies:
            response = self.reddit_session.request_json(self._fast_permalink)
            if response[1]['data']['children']:
                # pylint: disable=W0212
                self._replies = response[1]['data']['children'][0]._replies
            else:
                # comment is "specially deleted", a reddit inconsistency;
                # see #519, #524, #535, #537, and #552 it needs to be
                # retreived via /api/info, but that's okay since these
                # specially deleted comments always have the same json
                # structure.
                msg = ("Comment {0} was deleted or removed, and had "
                       "no replies when such happened, so it still "
                       "has no replies".format(self.name))
                warn(msg, RuntimeWarning)
                self._replies = []
            # pylint: enable=W0212
            self._has_fetched_replies = True
            # Set the submission object if it is not set.
            if not self._submission:
                self._submission = response[0]['data']['children'][0]
        return self._replies

    @property
    def submission(self):
        """Return the Submission object this comment belongs to."""
        if not self._submission:  # Comment not from submission
            self._submission = self.reddit_session.get_submission(
                url=self._fast_permalink)
        return self._submission


class Message(Inboxable):
    """A class for private messages."""

    @staticmethod
    @restrict_access(scope='privatemessages')
    def from_id(reddit_session, message_id, *args, **kwargs):
        """Request the url for a Message and return a Message object.

        :param reddit_session: The session to make the request with.
        :param message_id: The ID of the message to request.

        The additional parameters are passed directly into
        :meth:`.request_json`.

        """
        # Reduce fullname to ID if necessary
        message_id = message_id.split('_', 1)[-1]
        url = reddit_session.config['message'].format(messageid=message_id)
        message_info = reddit_session.request_json(url, *args, **kwargs)
        message = message_info['data']['children'][0]

        # Messages are received as a listing such that
        # the first item is always the thread's root.
        # The ID requested by the user may be a child.
        if message.id == message_id:
            return message
        for child in message.replies:
            if child.id == message_id:
                return child

    def __init__(self, reddit_session, json_dict):
        """Construct an instance of the Message object."""
        super(Message, self).__init__(reddit_session, json_dict)
        if self.replies:  # pylint: disable=E0203
            self.replies = self.replies['data']['children']
        else:
            self.replies = []

    @limit_chars
    def __unicode__(self):
        """Return a string representation of the Message."""
        return 'From: {0}\nSubject: {1}\n\n{2}'.format(self.author,
                                                       self.subject, self.body)

    @restrict_access(scope='privatemessages')
    def collapse(self):
        """Collapse a private message or modmail."""
        url = self.reddit_session.config['collapse_message']
        self.reddit_session.request_json(url, data={'id': self.name})

    @restrict_access(scope='modcontributors')
    def mute_modmail_author(self, _unmute=False):
        """Mute the sender of this modmail message.

        :param _unmute: Unmute the user instead. Please use
            :meth:`unmute_modmail_author` instead of setting this directly.

        """
        path = 'unmute_sender' if _unmute else 'mute_sender'
        return self.reddit_session.request_json(
            self.reddit_session.config[path], data={'id': self.fullname})

    @restrict_access(scope='privatemessages')
    def uncollapse(self):
        """Uncollapse a private message or modmail."""
        url = self.reddit_session.config['uncollapse_message']
        self.reddit_session.request_json(url, data={'id': self.name})

    def unmute_modmail_author(self):
        """Unmute the sender of this modmail message."""
        return self.mute_modmail_author(_unmute=True)


class MoreComments(RedditContentObject):
    """A class indicating there are more comments."""

    def __init__(self, reddit_session, json_dict):
        """Construct an instance of the MoreComment object."""
        super(MoreComments, self).__init__(reddit_session, json_dict)
        self.submission = None
        self._comments = None

    def __lt__(self, other):
        """Proide a sort order on the MoreComments object."""
        # To work with heapq a "smaller" item is the one with the most comments
        # We are intentionally making the biggest element the smallest element
        # to turn the min-heap implementation in heapq into a max-heap
        # implementation for Submission.replace_more_comments()
        return self.count > other.count

    def __unicode__(self):
        """Return a string representation of the MoreComments object."""
        return '[More Comments: {0}]'.format(self.count)

    def _continue_comments(self, update):
        assert len(self.children) > 0
        tmp = self.reddit_session.get_submission(urljoin(
            self.submission.permalink, self.parent_id.split('_', 1)[1]))
        assert len(tmp.comments) == 1
        self._comments = tmp.comments[0].replies
        if update:
            for comment in self._comments:
                # pylint: disable=W0212
                comment._update_submission(self.submission)
                # pylint: enable=W0212
        return self._comments

    def _update_submission(self, submission):
        self.submission = submission

    def comments(self, update=True):
        """Fetch and return the comments for a single MoreComments object."""
        if not self._comments:
            if self.count == 0:  # Handle 'continue this thread' type
                return self._continue_comments(update)
            # pylint: disable=W0212
            children = [x for x in self.children if 't1_{0}'.format(x)
                        not in self.submission._comments_by_id]
            # pylint: enable=W0212
            if not children:
                return None
            data = {'children': ','.join(children),
                    'link_id': self.submission.fullname,
                    'r': str(self.submission.subreddit)}

            # pylint: disable=W0212
            if self.submission._comment_sort:
                data['where'] = self.submission._comment_sort
            # pylint: enable=W0212
            url = self.reddit_session.config['morechildren']
            response = self.reddit_session.request_json(url, data=data)
            self._comments = response['data']['things']
            if update:
                for comment in self._comments:
                    # pylint: disable=W0212
                    comment._update_submission(self.submission)
                    # pylint: enable=W0212
        return self._comments


class Redditor(Gildable, Messageable, Refreshable):
    """A class representing the users of reddit."""

    _methods = (('get_multireddit', MultiMix), ('get_multireddits', MultiMix))

    get_comments = _get_redditor_listing('comments')
    get_overview = _get_redditor_listing('')
    get_submitted = _get_redditor_listing('submitted')

    def __init__(self, reddit_session, user_name=None, json_dict=None,
                 fetch=False, **kwargs):
        """Construct an instance of the Redditor object."""
        if not user_name:
            user_name = json_dict['name']
        info_url = reddit_session.config['user_about'].format(user=user_name)
        # name is set before calling the parent constructor so that the
        # json_dict 'name' attribute (if available) has precedence
        self._case_name = user_name
        super(Redditor, self).__init__(reddit_session, json_dict,
                                       fetch, info_url, **kwargs)
        self.name = self._case_name
        self._url = reddit_session.config['user'].format(user=self.name)
        self._mod_subs = None

    def __repr__(self):
        """Return a code representation of the Redditor."""
        return 'Redditor(user_name=\'{0}\')'.format(self.name)

    def __unicode__(self):
        """Return a string representation of the Redditor."""
        return self.name

    def _post_populate(self, fetch):
        if fetch:
            # Maintain a consistent `name` until the user
            # explicitly calls `redditor.refresh()`
            tmp = self._case_name
            self._case_name = self.name
            self.name = tmp

    @restrict_access(scope='subscribe')
    def friend(self, note=None, _unfriend=False):
        """Friend the user.

        :param note: A personal note about the user. Requires reddit Gold.
        :param _unfriend: Unfriend the user. Please use :meth:`unfriend`
            instead of setting this parameter manually.

        :returns: The json response from the server.

        """
        self.reddit_session.evict(self.reddit_session.config['friends'])

        # Requests through password auth use /api/friend
        # Requests through oauth use /api/v1/me/friends/{username}
        if not self.reddit_session.is_oauth_session():
            modifier = _modify_relationship('friend', unlink=_unfriend)
            data = {'note': note} if note else {}
            return modifier(self.reddit_session.user, self, **data)

        url = self.reddit_session.config['friend_v1'].format(user=self.name)
        # This endpoint wants the data to be a string instead of an actual
        # dictionary, although it is not required to have any content for adds.
        # Unfriending does require the 'id' key.
        if _unfriend:
            data = {'id': self.name}
        else:
            # We cannot send a null or empty note string.
            data = {'note': note} if note else {}
        data = dumps(data)
        method = 'DELETE' if _unfriend else 'PUT'
        return self.reddit_session.request_json(url, data=data, method=method)

    def get_disliked(self, *args, **kwargs):
        """Return a listing of the Submissions the user has downvoted.

        This method points to :meth:`get_downvoted`, as the "disliked" name
        is being phased out.
        """
        return self.get_downvoted(*args, **kwargs)

    def get_downvoted(self, *args, **kwargs):
        """Return a listing of the Submissions the user has downvoted.

        :returns: get_content generator of Submission items.

        The additional parameters are passed directly into
        :meth:`.get_content`. Note: the `url` parameter cannot be altered.

        As a default, this listing is only accessible by the user. Thereby
        requiring either user/pswd authentication or OAuth authentication with
        the 'history' scope. Users may choose to make their voting record
        public by changing a user preference. In this case, no authentication
        will be needed to access this listing.

        """
        # Sending an OAuth authenticated request for a redditor, who isn't the
        # authenticated user. But who has a public voting record will be
        # successful.
        kwargs['_use_oauth'] = self.reddit_session.is_oauth_session()
        return _get_redditor_listing('downvoted')(self, *args, **kwargs)

    @restrict_access(scope='mysubreddits')
    def get_friend_info(self):
        """Return information about this friend, including personal notes.

        The personal note can be added or overwritten with :meth:friend, but
            only if the user has reddit Gold.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['friend_v1'].format(user=self.name)
        data = {'id': self.name}
        return self.reddit_session.request_json(url, data=data, method='GET')

    def get_liked(self, *args, **kwargs):
        """Return a listing of the Submissions the user has upvoted.

        This method points to :meth:`get_upvoted`, as the "liked" name
        is being phased out.
        """
        return self.get_upvoted(*args, **kwargs)

    def get_upvoted(self, *args, **kwargs):
        """Return a listing of the Submissions the user has upvoted.

        :returns: get_content generator of Submission items.

        The additional parameters are passed directly into
        :meth:`.get_content`. Note: the `url` parameter cannot be altered.

        As a default, this listing is only accessible by the user. Thereby
        requirering either user/pswd authentication or OAuth authentication
        with the 'history' scope. Users may choose to make their voting record
        public by changing a user preference. In this case, no authentication
        will be needed to access this listing.

        """
        kwargs['_use_oauth'] = self.reddit_session.is_oauth_session()
        return _get_redditor_listing('upvoted')(self, *args, **kwargs)

    def mark_as_read(self, messages, unread=False):
        """Mark message(s) as read or unread.

        :returns: The json response from the server.

        """
        ids = []
        if isinstance(messages, Inboxable):
            ids.append(messages.fullname)
        elif hasattr(messages, '__iter__'):
            for msg in messages:
                if not isinstance(msg, Inboxable):
                    msg = 'Invalid message type: {0}'.format(type(msg))
                    raise ClientException(msg)
                ids.append(msg.fullname)
        else:
            msg = 'Invalid message type: {0}'.format(type(messages))
            raise ClientException(msg)
        # pylint: disable=W0212
        retval = self.reddit_session._mark_as_read(ids, unread=unread)
        # pylint: enable=W0212
        return retval

    def unfriend(self):
        """Unfriend the user.

        :returns: The json response from the server.

        """
        return self.friend(_unfriend=True)


class LoggedInRedditor(Redditor):
    """A class representing a currently logged in Redditor."""

    get_hidden = restrict_access('history')(_get_redditor_listing('hidden'))
    get_saved = restrict_access('history')(_get_redditor_listing('saved'))

    def get_blocked(self):
        """Return a UserList of Redditors with whom the user has blocked."""
        url = self.reddit_session.config['blocked']
        return self.reddit_session.request_json(url)

    def get_cached_moderated_reddits(self):
        """Return a cached dictionary of the user's moderated reddits.

        This list is used internally. Consider using the `get_my_moderation`
        function instead.

        """
        if self._mod_subs is None:
            self._mod_subs = {'mod': self.reddit_session.get_subreddit('mod')}
            for sub in self.reddit_session.get_my_moderation(limit=None):
                self._mod_subs[six.text_type(sub).lower()] = sub
        return self._mod_subs

    @deprecated('``get_friends`` has been moved to '
                ':class:`praw.AuthenticatedReddit` and will be removed from '
                ':class:`objects.LoggedInRedditor` in PRAW v4.0.0')
    def get_friends(self, **params):
        """Return a UserList of Redditors with whom the user is friends.

        This method has been moved to :class:`praw.AuthenticatedReddit`.

        """
        return self.reddit_session.get_friends(**params)


class ModAction(RedditContentObject):
    """A moderator action."""

    def __init__(self, reddit_session, json_dict=None, fetch=False):
        """Construct an instance of the ModAction object."""
        super(ModAction, self).__init__(reddit_session, json_dict, fetch)

    def __unicode__(self):
        """Return a string reprsentation of the moderator action."""
        return 'Action: {0}'.format(self.action)


class Submission(Editable, Gildable, Hideable, Moderatable, Refreshable,
                 Reportable, Saveable, Voteable):
    """A class for submissions to reddit."""

    _methods = (('select_flair', AR),)

    @staticmethod
    def _extract_more_comments(tree):
        """Return a list of MoreComments objects removed from tree."""
        more_comments = []
        queue = [(None, x) for x in tree]
        while len(queue) > 0:
            parent, comm = queue.pop(0)
            if isinstance(comm, MoreComments):
                heappush(more_comments, comm)
                if parent:
                    parent.replies.remove(comm)
                else:
                    tree.remove(comm)
            else:
                for item in comm.replies:
                    queue.append((comm, item))
        return more_comments

    @staticmethod
    def from_id(reddit_session, subreddit_id):
        """Return an edit-only submission object based on the id."""
        pseudo_data = {'id': subreddit_id,
                       'permalink': '/comments/{0}'.format(subreddit_id)}
        return Submission(reddit_session, pseudo_data)

    @staticmethod
    def from_json(json_response):
        """Return a submission object from the json response."""
        submission = json_response[0]['data']['children'][0]
        submission.comments = json_response[1]['data']['children']
        return submission

    @staticmethod
    @restrict_access(scope='read')
    def from_url(reddit_session, url, comment_limit=0, comment_sort=None,
                 comments_only=False, params=None):
        """Request the url and return a Submission object.

        :param reddit_session: The session to make the request with.
        :param url: The url to build the Submission object from.
        :param comment_limit: The desired number of comments to fetch. If <= 0
            fetch the default number for the session's user. If None, fetch the
            maximum possible.
        :param comment_sort: The sort order for retrieved comments. When None
            use the default for the session's user.
        :param comments_only: Return only the list of comments.
        :param params: dictionary containing extra GET data to put in the url.

        """
        if params is None:
            params = {}

        parsed = urlparse(url)
        query_pairs = parse_qs(parsed.query)
        get_params = dict((k, ",".join(v)) for k, v in query_pairs.items())
        params.update(get_params)
        url = urlunparse(parsed[:3] + ("", "", ""))
        if comment_limit is None:  # Fetch MAX
            params['limit'] = 2048  # Just use a big number
        elif comment_limit > 0:  # Use value
            params['limit'] = comment_limit
        if comment_sort:
            params['sort'] = comment_sort

        response = reddit_session.request_json(url, params=params)
        if comments_only:
            return response[1]['data']['children']
        submission = Submission.from_json(response)
        submission._comment_sort = comment_sort  # pylint: disable=W0212
        submission._params = params  # pylint: disable=W0212
        return submission

    def __init__(self, reddit_session, json_dict):
        """Construct an instance of the Subreddit object."""
        super(Submission, self).__init__(reddit_session, json_dict)
        # pylint: disable=E0203
        self._api_link = urljoin(reddit_session.config.api_url, self.permalink)
        # pylint: enable=E0203
        self.permalink = urljoin(reddit_session.config.permalink_url,
                                 self.permalink)
        self._comment_sort = None
        self._comments_by_id = {}
        self._comments = None
        self._orphaned = {}
        self._replaced_more = False
        self._params = {}

    @limit_chars
    def __unicode__(self):
        """Return a string representation of the Subreddit.

        Note: The representation is truncated to a fix number of characters.
        """
        title = self.title.replace('\r\n', ' ')
        return six.text_type('{0} :: {1}').format(self.score, title)

    def _insert_comment(self, comment):
        if comment.name in self._comments_by_id:  # Skip existing comments
            return

        comment._update_submission(self)  # pylint: disable=W0212

        if comment.name in self._orphaned:  # Reunite children with parent
            comment.replies.extend(self._orphaned[comment.name])
            del self._orphaned[comment.name]

        if comment.is_root:
            self._comments.append(comment)
        elif comment.parent_id in self._comments_by_id:
            self._comments_by_id[comment.parent_id].replies.append(comment)
        else:  # Orphan
            if comment.parent_id in self._orphaned:
                self._orphaned[comment.parent_id].append(comment)
            else:
                self._orphaned[comment.parent_id] = [comment]

    def _update_comments(self, comments):
        self._comments = comments
        for comment in self._comments:
            comment._update_submission(self)  # pylint: disable=W0212

    def add_comment(self, text):
        """Comment on the submission using the specified text.

        :returns: A Comment object for the newly created comment.

        """
        # pylint: disable=W0212
        response = self.reddit_session._add_comment(self.fullname, text)
        # pylint: enable=W0212
        self.reddit_session.evict(self._api_link)  # pylint: disable=W0212
        return response

    @property
    def comments(self):  # pylint: disable=E0202
        """Return forest of comments, with top-level comments as tree roots.

        May contain instances of MoreComment objects. To easily replace these
        objects with Comment objects, use the replace_more_comments method then
        fetch this attribute. Use comment replies to walk down the tree. To get
        an unnested, flat list of comments from this attribute use
        helpers.flatten_tree.

        """
        if self._comments is None:
            self.comments = Submission.from_url(  # pylint: disable=W0212
                self.reddit_session, self._api_link, comments_only=True)
        return self._comments

    @comments.setter  # NOQA
    def comments(self, new_comments):  # pylint: disable=E0202
        """Update the list of comments with the provided nested list."""
        self._update_comments(new_comments)
        self._orphaned = {}

    def get_duplicates(self, *args, **kwargs):
        """Return a get_content generator for the submission's duplicates.

        :returns: get_content generator iterating over Submission objects.

        The additional parameters are passed directly into
        :meth:`.get_content`. Note: the `url` and `object_filter` parameters
        cannot be altered.

        """
        url = self.reddit_session.config['duplicates'].format(
            submissionid=self.id)
        return self.reddit_session.get_content(url, *args, object_filter=1,
                                               **kwargs)

    def get_flair_choices(self, *args, **kwargs):
        """Return available link flair choices and current flair.

        Convenience function for
        :meth:`~.AuthenticatedReddit.get_flair_choices` populating both the
        `subreddit` and `link` parameters.

        :returns: The json response from the server.

        """
        return self.subreddit.get_flair_choices(self.fullname, *args, **kwargs)

    @restrict_access(scope='modposts')
    def lock(self):
        """Lock thread.

        Requires that the currently authenticated user has the modposts oauth
        scope or has user/password authentication as a mod of the subreddit.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['lock']
        data = {'id': self.fullname}
        return self.reddit_session.request_json(url, data=data)

    def mark_as_nsfw(self, unmark_nsfw=False):
        """Mark as Not Safe For Work.

        Requires that the currently authenticated user is the author of the
        submission, has the modposts oauth scope or has user/password
        authentication as a mod of the subreddit.

        :returns: The json response from the server.

        """
        def mark_as_nsfw_helper(self):  # pylint: disable=W0613
            # It is necessary to have the 'self' argument as it's needed in
            # restrict_access to determine what class the decorator is
            # operating on.
            url = self.reddit_session.config['unmarknsfw' if unmark_nsfw else
                                             'marknsfw']
            data = {'id': self.fullname}
            return self.reddit_session.request_json(url, data=data)

        is_author = (self.reddit_session.is_logged_in() and self.author ==
                     self.reddit_session.user)
        if is_author:
            return mark_as_nsfw_helper(self)
        else:
            return restrict_access('modposts')(mark_as_nsfw_helper)(self)

    def replace_more_comments(self, limit=32, threshold=1):
        """Update the comment tree by replacing instances of MoreComments.

        :param limit: The maximum number of MoreComments objects to
            replace. Each replacement requires 1 API request. Set to None to
            have no limit, or to 0 to make no extra requests. Default: 32
        :param threshold: The minimum number of children comments a
            MoreComments object must have in order to be replaced. Default: 1
        :returns: A list of MoreComments objects that were not replaced.

        Note that after making this call, the `comments` attribute of the
        submission will no longer contain any MoreComments objects. Items that
        weren't replaced are still removed from the tree, and will be included
        in the returned list.

        """
        if self._replaced_more:
            return []

        remaining = limit
        more_comments = self._extract_more_comments(self.comments)
        skipped = []

        # Fetch largest more_comments until reaching the limit or the threshold
        while more_comments:
            item = heappop(more_comments)
            if remaining == 0:  # We're not going to replace any more
                heappush(more_comments, item)  # It wasn't replaced
                break
            elif len(item.children) == 0 or 0 < item.count < threshold:
                heappush(skipped, item)  # It wasn't replaced
                continue

            # Fetch new comments and decrease remaining if a request was made
            new_comments = item.comments(update=False)
            if new_comments is not None and remaining is not None:
                remaining -= 1
            elif new_comments is None:
                continue

            # Re-add new MoreComment objects to the heap of more_comments
            for more in self._extract_more_comments(new_comments):
                more._update_submission(self)  # pylint: disable=W0212
                heappush(more_comments, more)
            # Insert the new comments into the tree
            for comment in new_comments:
                self._insert_comment(comment)

        self._replaced_more = True
        return more_comments + skipped

    def set_flair(self, *args, **kwargs):
        """Set flair for this submission.

        Convenience function that utilizes :meth:`.ModFlairMixin.set_flair`
        populating both the `subreddit` and `item` parameters.

        :returns: The json response from the server.

        """
        return self.subreddit.set_flair(self, *args, **kwargs)

    @restrict_access(scope='modposts')
    def set_contest_mode(self, state=True):
        """Set 'Contest Mode' for the comments of this submission.

        Contest mode have the following effects:
          * The comment thread will default to being sorted randomly.
          * Replies to top-level comments will be hidden behind
            "[show replies]" buttons.
          * Scores will be hidden from non-moderators.
          * Scores accessed through the API (mobile apps, bots) will be
            obscured to "1" for non-moderators.

        Source for effects: https://www.reddit.com/159bww/

        :returns: The json response from the server.

        """
        # TODO: Whether a submission is in contest mode is not exposed via the
        # API. Adding a test of this method is thus currently impossible.
        # Add a test when it becomes possible.
        url = self.reddit_session.config['contest_mode']
        data = {'id': self.fullname, 'state': state}
        return self.reddit_session.request_json(url, data=data)

    @restrict_access(scope='modposts')
    def set_suggested_sort(self, sort='blank'):
        """Set 'Suggested Sort' for the comments of the submission.

        Comments can be sorted in one of (confidence, top, new, hot,
        controversial, old, random, qa, blank).

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['suggested_sort']
        data = {'id': self.fullname, 'sort': sort}
        return self.reddit_session.request_json(url, data=data)

    @property
    def short_link(self):
        """Return a short link to the submission.

        The short link points to a page on the short_domain that redirects to
        the main. For example http://redd.it/eorhm is a short link for
        https://www.reddit.com/r/announcements/comments/eorhm/reddit_30_less_typing/.

        """
        return urljoin(self.reddit_session.config.short_domain, self.id)

    @restrict_access(scope='modposts')
    def sticky(self, bottom=True):
        """Sticky a post in its subreddit.

        If there is already a stickied post in the designated slot it will be
        unstickied.

        :param bottom: Set this as the top or bottom sticky. If no top sticky
            exists, this submission will become the top sticky regardless.

        :returns: The json response from the server

        """
        url = self.reddit_session.config['sticky_submission']
        data = {'id': self.fullname, 'state': True}
        if not bottom:
            data['num'] = 1
        return self.reddit_session.request_json(url, data=data)

    @restrict_access(scope='modposts')
    def unlock(self):
        """Lock thread.

        Requires that the currently authenticated user has the modposts oauth
        scope or has user/password authentication as a mod of the subreddit.

        :returns: The json response from the server.

        """
        url = self.reddit_session.config['unlock']
        data = {'id': self.fullname}
        return self.reddit_session.request_json(url, data=data)

    def unmark_as_nsfw(self):
        """Mark as Safe For Work.

        :returns: The json response from the server.

        """
        return self.mark_as_nsfw(unmark_nsfw=True)

    @restrict_access(scope='modposts')
    def unset_contest_mode(self):
        """Unset 'Contest Mode' for the comments of this submission.

        Contest mode have the following effects:
          * The comment thread will default to being sorted randomly.
          * Replies to top-level comments will be hidden behind
            "[show replies]" buttons.
          * Scores will be hidden from non-moderators.
          * Scores accessed through the API (mobile apps, bots) will be
            obscured to "1" for non-moderators.

        Source for effects: http://www.reddit.com/159bww/

        :returns: The json response from the server.

        """
        return self.set_contest_mode(False)

    @restrict_access(scope='modposts')
    def unsticky(self):
        """Unsticky this post.

        :returns: The json response from the server

        """
        url = self.reddit_session.config['sticky_submission']
        data = {'id': self.fullname, 'state': False}
        return self.reddit_session.request_json(url, data=data)


class Subreddit(Messageable, Refreshable):
    """A class for Subreddits."""

    _methods = (('accept_moderator_invite', AR),
                ('add_flair_template', MFMix),
                ('clear_flair_templates', MFMix),
                ('configure_flair', MFMix),
                ('delete_flair', MFMix),
                ('delete_image', MCMix),
                ('edit_wiki_page', AR),
                ('get_banned', MOMix),
                ('get_comments', UR),
                ('get_contributors', MOMix),
                ('get_edited', MOMix),
                ('get_flair', UR),
                ('get_flair_choices', AR),
                ('get_flair_list', MFMix),
                ('get_moderators', UR),
                ('get_mod_log', MLMix),
                ('get_mod_queue', MOMix),
                ('get_mod_mail', MOMix),
                ('get_muted', MOMix),
                ('get_random_submission', UR),
                ('get_reports', MOMix),
                ('get_rules', UR),
                ('get_settings', MCMix),
                ('get_spam', MOMix),
                ('get_sticky', UR),
                ('get_stylesheet', MOMix),
                ('get_traffic', UR),
                ('get_unmoderated', MOMix),
                ('get_wiki_banned', MOMix),
                ('get_wiki_contributors', MOMix),
                ('get_wiki_page', UR),
                ('get_wiki_pages', UR),
                ('leave_contributor', MSMix),
                ('leave_moderator', MSMix),
                ('search', UR),
                ('select_flair', AR),
                ('set_flair', MFMix),
                ('set_flair_csv', MFMix),
                ('set_settings', MCMix),
                ('set_stylesheet', MCMix),
                ('submit', SubmitMixin),
                ('subscribe', SubscribeMixin),
                ('unsubscribe', SubscribeMixin),
                ('update_settings', MCMix),
                ('upload_image', MCMix))

    # Subreddit banned
    add_ban = _modify_relationship('banned', is_sub=True)
    remove_ban = _modify_relationship('banned', unlink=True, is_sub=True)

    # Subreddit contributors
    add_contributor = _modify_relationship('contributor', is_sub=True)
    remove_contributor = _modify_relationship('contributor', unlink=True,
                                              is_sub=True)
    # Subreddit moderators
    add_moderator = _modify_relationship('moderator', is_sub=True)
    remove_moderator = _modify_relationship('moderator', unlink=True,
                                            is_sub=True)
    # Subreddit muted
    add_mute = _modify_relationship('muted', is_sub=True)
    remove_mute = _modify_relationship('muted', is_sub=True, unlink=True)

    # Subreddit wiki banned
    add_wiki_ban = _modify_relationship('wikibanned', is_sub=True)
    remove_wiki_ban = _modify_relationship('wikibanned', unlink=True,
                                           is_sub=True)
    # Subreddit wiki contributors
    add_wiki_contributor = _modify_relationship('wikicontributor', is_sub=True)
    remove_wiki_contributor = _modify_relationship('wikicontributor',
                                                   unlink=True, is_sub=True)

    # Generic listing selectors
    get_controversial = _get_sorter('controversial')
    get_hot = _get_sorter('')
    get_new = _get_sorter('new')
    get_top = _get_sorter('top')
    get_gilded = _get_sorter('gilded')

    # Explicit listing selectors
    get_controversial_from_all = _get_sorter('controversial', t='all')
    get_controversial_from_day = _get_sorter('controversial', t='day')
    get_controversial_from_hour = _get_sorter('controversial', t='hour')
    get_controversial_from_month = _get_sorter('controversial', t='month')
    get_controversial_from_week = _get_sorter('controversial', t='week')
    get_controversial_from_year = _get_sorter('controversial', t='year')
    get_rising = _get_sorter('rising')
    get_top_from_all = _get_sorter('top', t='all')
    get_top_from_day = _get_sorter('top', t='day')
    get_top_from_hour = _get_sorter('top', t='hour')
    get_top_from_month = _get_sorter('top', t='month')
    get_top_from_week = _get_sorter('top', t='week')
    get_top_from_year = _get_sorter('top', t='year')

    def __init__(self, reddit_session, subreddit_name=None, json_dict=None,
                 fetch=False, **kwargs):
        """Construct an instance of the Subreddit object."""
        # Special case for when my_subreddits is called as no name is returned
        # so we have to extract the name from the URL. The URLs are returned
        # as: /r/reddit_name/
        if subreddit_name is None:
            subreddit_name = json_dict['url'].split('/')[2]

        if not isinstance(subreddit_name, six.string_types) \
                or not subreddit_name:
            raise TypeError('subreddit_name must be a non-empty string.')

        if fetch and ('+' in subreddit_name or '-' in subreddit_name):
            fetch = False
            warn_explicit('fetch=True has no effect on multireddits',
                          UserWarning, '', 0)

        info_url = reddit_session.config['subreddit_about'].format(
            subreddit=subreddit_name)
        self._case_name = subreddit_name
        super(Subreddit, self).__init__(reddit_session, json_dict, fetch,
                                        info_url, **kwargs)
        self.display_name = self._case_name
        self._url = reddit_session.config['subreddit'].format(
            subreddit=self.display_name)
        # '' is the hot listing
        listings = ['new/', '', 'top/', 'controversial/', 'rising/']
        base = reddit_session.config['subreddit'].format(
            subreddit=self.display_name)
        self._listing_urls = [base + x + '.json' for x in listings]

    def __repr__(self):
        """Return a code representation of the Subreddit."""
        return 'Subreddit(subreddit_name=\'{0}\')'.format(self.display_name)

    def __unicode__(self):
        """Return a string representation of the Subreddit."""
        return self.display_name

    def _post_populate(self, fetch):
        if fetch:
            # Maintain a consistent `display_name` until the user
            # explicitly calls `subreddit.refresh()`
            tmp = self._case_name
            self._case_name = self.display_name
            self.display_name = tmp

    def clear_all_flair(self):
        """Remove all user flair on this subreddit.

        :returns: The json response from the server when there is flair to
            clear, otherwise returns None.

        """
        csv = [{'user': x['user']} for x in self.get_flair_list(limit=None)]
        if csv:
            return self.set_flair_csv(csv)
        else:
            return


class Multireddit(Refreshable):
    """A class for users' Multireddits."""

    # 2017-11-13
    # Several of the @restrict_access decorators have been removed here,
    # because they were duplicated in the corresponding reddit_session
    # methods and raised assertion errors. The is the same category of
    # bug as this issue:
    #     https://github.com/praw-dev/praw/issues/477

    # Generic listing selectors
    get_controversial = _get_sorter('controversial')
    get_hot = _get_sorter('')
    get_new = _get_sorter('new')
    get_top = _get_sorter('top')

    # Explicit listing selectors
    get_controversial_from_all = _get_sorter('controversial', t='all')
    get_controversial_from_day = _get_sorter('controversial', t='day')
    get_controversial_from_hour = _get_sorter('controversial', t='hour')
    get_controversial_from_month = _get_sorter('controversial', t='month')
    get_controversial_from_week = _get_sorter('controversial', t='week')
    get_controversial_from_year = _get_sorter('controversial', t='year')
    get_rising = _get_sorter('rising')
    get_top_from_all = _get_sorter('top', t='all')
    get_top_from_day = _get_sorter('top', t='day')
    get_top_from_hour = _get_sorter('top', t='hour')
    get_top_from_month = _get_sorter('top', t='month')
    get_top_from_week = _get_sorter('top', t='week')
    get_top_from_year = _get_sorter('top', t='year')

    @classmethod
    def from_api_response(cls, reddit_session, json_dict):
        """Return an instance of the appropriate class from the json dict."""
        # The Multireddit response contains the Subreddits attribute as a list
        # of dicts of the form {'name': 'subredditname'}.
        # We must convert each of these into a Subreddit object.
        json_dict['subreddits'] = [Subreddit(reddit_session, item['name'])
                                   for item in json_dict['subreddits']]
        return cls(reddit_session, None, None, json_dict)

    def __init__(self, reddit_session, author=None, name=None,
                 json_dict=None, fetch=False, **kwargs):
        """Construct an instance of the Multireddit object."""

        # When get_my_multireddits is called, we extract the author
        # and multireddit name from the path. A trailing forward
        # slash was recently added to the path string in the API
        # response, the needs to be removed to fix the code.
        # path = "/user/redditor/m/multi/"
        if json_dict and json_dict['path']:
            json_dict['path'] = json_dict['path'].rstrip('/')

        author = six.text_type(author) if author \
            else json_dict['path'].split('/')[-3]
        if not name:
            name = json_dict['path'].split('/')[-1]

        info_url = reddit_session.config['multireddit_about'].format(
            user=author, multi=name)
        self.name = name
        self._author = author
        super(Multireddit, self).__init__(reddit_session, json_dict, fetch,
                                          info_url, **kwargs)
        self._url = reddit_session.config['multireddit'].format(
            user=author, multi=name)

    def __repr__(self):
        """Return a code representation of the Multireddit."""
        return 'Multireddit(author=\'{0}\', name=\'{1}\')'.format(
            self._author, self.name)

    def __unicode__(self):
        """Return a string representation of the Multireddit."""
        return self.name

    def _post_populate(self, fetch):
        if fetch:
            # Subreddits are returned as dictionaries in the form
            # {'name': 'subredditname'}. Convert them to Subreddit objects.
            self.subreddits = [Subreddit(self.reddit_session, item['name'])
                               for item in self.subreddits]

            # paths are of the form "/user/{USERNAME}/m/{MULTINAME}"
            author = self.path.split('/')[2]
            self.author = Redditor(self.reddit_session, author)

    @restrict_access(scope='subscribe')
    def add_subreddit(self, subreddit, _delete=False, *args, **kwargs):
        """Add a subreddit to the multireddit.

        :param subreddit: The subreddit name or Subreddit object to add

        The additional parameters are passed directly into
        :meth:`~praw.__init__.BaseReddit.request_json`.

        """
        subreddit = six.text_type(subreddit)
        url = self.reddit_session.config['multireddit_add'].format(
            user=self._author, multi=self.name, subreddit=subreddit)
        method = 'DELETE' if _delete else 'PUT'
        # The modhash isn't necessary for OAuth requests
        if not self.reddit_session._use_oauth:
            self.reddit_session.http.headers['x-modhash'] = \
                self.reddit_session.modhash
        data = {'model': dumps({'name': subreddit})}
        try:
            self.reddit_session.request(url, data=data, method=method,
                                        *args, **kwargs)
        finally:
            # The modhash isn't necessary for OAuth requests
            if not self.reddit_session._use_oauth:
                del self.reddit_session.http.headers['x-modhash']

    def copy(self, to_name):
        """Copy this multireddit.

        Convenience function that utilizes
        :meth:`.MultiredditMixin.copy_multireddit` populating both
        the `from_redditor` and `from_name` parameters.

        """
        return self.reddit_session.copy_multireddit(self._author, self.name,
                                                    to_name)

    def delete(self):
        """Delete this multireddit.

        Convenience function that utilizes
        :meth:`.MultiredditMixin.delete_multireddit` populating the `name`
        parameter.

        """
        return self.reddit_session.delete_multireddit(self.name)

    def edit(self, *args, **kwargs):
        """Edit this multireddit.

        Convenience function that utilizes
        :meth:`.MultiredditMixin.edit_multireddit` populating the `name`
        parameter.

        """
        return self.reddit_session.edit_multireddit(name=self.name, *args,
                                                    **kwargs)

    def remove_subreddit(self, subreddit, *args, **kwargs):
        """Remove a subreddit from the user's multireddit."""
        return self.add_subreddit(subreddit, True, *args, **kwargs)

    def rename(self, new_name, *args, **kwargs):
        """Rename this multireddit.

        This function is a handy shortcut to
        :meth:`rename_multireddit` of the reddit_session.

        """
        new = self.reddit_session.rename_multireddit(self.name, new_name,
                                                     *args, **kwargs)
        self.__dict__ = new.__dict__  # pylint: disable=W0201
        return self


class PRAWListing(RedditContentObject):
    """An abstract class to coerce a listing into RedditContentObjects."""

    CHILD_ATTRIBUTE = None

    def __init__(self, reddit_session, json_dict=None, fetch=False):
        """Construct an instance of the PRAWListing object."""
        super(PRAWListing, self).__init__(reddit_session, json_dict, fetch)

        if not self.CHILD_ATTRIBUTE:
            raise NotImplementedError('PRAWListing must be extended.')

        child_list = getattr(self, self.CHILD_ATTRIBUTE)
        for i in range(len(child_list)):
            child_list[i] = self._convert(reddit_session, child_list[i])

    def __contains__(self, item):
        """Test if item exists in the listing."""
        return item in getattr(self, self.CHILD_ATTRIBUTE)

    def __delitem__(self, index):
        """Remove the item at position index from the listing."""
        del getattr(self, self.CHILD_ATTRIBUTE)[index]

    def __getitem__(self, index):
        """Return the item at position index in the listing."""
        return getattr(self, self.CHILD_ATTRIBUTE)[index]

    def __iter__(self):
        """Return an iterator to the listing."""
        return getattr(self, self.CHILD_ATTRIBUTE).__iter__()

    def __len__(self):
        """Return the number of items in the listing."""
        return len(getattr(self, self.CHILD_ATTRIBUTE))

    def __setitem__(self, index, item):
        """Set item at position `index` in the listing."""
        getattr(self, self.CHILD_ATTRIBUTE)[index] = item

    def __unicode__(self):
        """Return a string representation of the listing."""
        return six.text_type(getattr(self, self.CHILD_ATTRIBUTE))


class UserList(PRAWListing):
    """A list of Redditors. Works just like a regular list."""

    CHILD_ATTRIBUTE = 'children'

    @staticmethod
    def _convert(reddit_session, data):
        """Return a Redditor object from the data."""
        retval = Redditor(reddit_session, data['name'], fetch=False)
        retval.id = data['id'].split('_')[1]  # pylint: disable=C0103,W0201
        return retval


class WikiPage(Refreshable):
    """An individual WikiPage object."""

    @classmethod
    def from_api_response(cls, reddit_session, json_dict):
        """Return an instance of the appropriate class from the json_dict."""
        # The WikiPage response does not contain the necessary information
        # in the JSON response to determine the name of the page nor the
        # subreddit it belongs to. Thus we must extract this information
        # from the request URL.
        # pylint: disable=W0212
        parts = reddit_session._request_url.split('/', 6)
        # pylint: enable=W0212
        subreddit = parts[4]
        page = parts[6].split('.', 1)[0]
        return cls(reddit_session, subreddit, page, json_dict=json_dict)

    def __init__(self, reddit_session, subreddit=None, page=None,
                 json_dict=None, fetch=False, **kwargs):
        """Construct an instance of the WikiPage object."""
        if not subreddit and not page:
            subreddit = json_dict['sr']
            page = json_dict['page']
        info_url = reddit_session.config['wiki_page'].format(
            subreddit=six.text_type(subreddit), page=page)
        super(WikiPage, self).__init__(reddit_session, json_dict, fetch,
                                       info_url, **kwargs)
        self.page = page
        self.subreddit = subreddit

    def __unicode__(self):
        """Return a string representation of the page."""
        return six.text_type('{0}:{1}').format(self.subreddit, self.page)

    @restrict_access(scope='modwiki')
    def add_editor(self, username, _delete=False, *args, **kwargs):
        """Add an editor to this wiki page.

        :param username: The name or Redditor object of the user to add.
        :param _delete: If True, remove the user as an editor instead.
            Please use :meth:`remove_editor` rather than setting it manually.

        Additional parameters are passed into
        :meth:`~praw.__init__.BaseReddit.request_json`.
        """
        url = self.reddit_session.config['wiki_page_editor']
        url = url.format(subreddit=six.text_type(self.subreddit),
                         method='del' if _delete else 'add')

        data = {'page': self.page,
                'username': six.text_type(username)}
        return self.reddit_session.request_json(url, data=data, *args,
                                                **kwargs)

    @restrict_access(scope='modwiki')
    def get_settings(self, *args, **kwargs):
        """Return the settings for this wiki page.

        Includes permission level, names of editors, and whether
        the page is listed on /wiki/pages.

        Additional parameters are passed into
        :meth:`~praw.__init__.BaseReddit.request_json`
        """
        url = self.reddit_session.config['wiki_page_settings']
        url = url.format(subreddit=six.text_type(self.subreddit),
                         page=self.page)
        return self.reddit_session.request_json(url, *args, **kwargs)['data']

    def edit(self, *args, **kwargs):
        """Edit the wiki page.

        Convenience function that utilizes
        :meth:`.AuthenticatedReddit.edit_wiki_page` populating both the
        ``subreddit`` and ``page`` parameters.
        """
        return self.subreddit.edit_wiki_page(self.page, *args, **kwargs)

    @restrict_access(scope='modwiki')
    def edit_settings(self, permlevel, listed, *args, **kwargs):
        """Edit the settings for this individual wiki page.

        :param permlevel: Who can edit this page?
            (0) use subreddit wiki permissions, (1) only approved wiki
            contributors for this page may edit (see
            :meth:`~praw.objects.WikiPage.add_editor`), (2) only mods may edit
            and view
        :param listed: Show this page on the listing?
            True - Appear in /wiki/pages
            False - Do not appear in /wiki/pages
        :returns: The updated settings data.

        Additional parameters are passed into :meth:`request_json`.

        """
        url = self.reddit_session.config['wiki_page_settings']
        url = url.format(subreddit=six.text_type(self.subreddit),
                         page=self.page)
        data = {'permlevel': permlevel,
                'listed': 'on' if listed else 'off'}

        return self.reddit_session.request_json(url, data=data, *args,
                                                **kwargs)['data']

    def remove_editor(self, username, *args, **kwargs):
        """Remove an editor from this wiki page.

        :param username: The name or Redditor object of the user to remove.

        This method points to :meth:`add_editor` with _delete=True.

        Additional parameters are are passed to :meth:`add_editor` and
        subsequently into :meth:`~praw.__init__.BaseReddit.request_json`.
        """
        return self.add_editor(username=username, _delete=True, *args,
                               **kwargs)


class WikiPageListing(PRAWListing):
    """A list of WikiPages. Works just like a regular list."""

    CHILD_ATTRIBUTE = '_tmp'

    @staticmethod
    def _convert(reddit_session, data):
        """Return a WikiPage object from the data."""
        # TODO: The _request_url hack shouldn't be necessary
        # pylint: disable=W0212
        subreddit = reddit_session._request_url.rsplit('/', 4)[1]
        # pylint: enable=W0212
        return WikiPage(reddit_session, subreddit, data, fetch=False)


def _add_aliases():
    def predicate(obj):
        return inspect.isclass(obj) and hasattr(obj, '_methods')

    import inspect
    import sys

    for _, cls in inspect.getmembers(sys.modules[__name__], predicate):
        for name, mixin in cls._methods:  # pylint: disable=W0212
            setattr(cls, name, alias_function(getattr(mixin, name),
                                              mixin.__name__))
_add_aliases()
