from .interfaces import INetworkGraph
from Acquisition import Explicit
from BTrees import OOBTree
from collections import namedtuple
from datetime import datetime
from persistent import Persistent
from plone import api
from zope.interface import implementer

import logging
import pytz

logger = logging.getLogger("ploneintranet.network")


def decode(value, permissive=False):
    """Force all strings into unicode"""
    if not (permissive or isinstance(value, str)):
        raise AttributeError("Input must be unicode or utf-8 encoded string")
    return value


@implementer(INetworkGraph)
class NetworkGraph(Persistent, Explicit):
    """Stores a social network graph of users:

    - bookmarking/unbookmarking
    - following/unfollowing
    - watching/unwatching
    - liking/unliking
    - tagging/untagging

    other users, content objects, status updates, tags.

    All parameters with item_id or user_id references are resolvable,
    permanently stable, string ids.
    - StatusUpdates: a str() cast of status.id.
    - content: a uuid on the content.
    - users: a stable userid (not a changeable email)
    - tags: merging or renaming tags requires migrating the tag storage

    Be aware that if item_type=="user", both item_id and user_id
    are user ids. In that case user_id is the actor (subject)
    and item_id is the target (object).
    For example if Alex endorses Bernard with tag 'leadership',
    then: item_type='user', item_id='bernard', user_id='alex'.

    Return values are normally BTrees.OOBTree.OOTreeSet iterables,
    except in some special cases documented on the tags accessors below,
    where a flattened structure is returned using the unpack()
    utility method.
    """

    # These statics define the data storage schema "item_type" axes.
    # If you change them you need to carefully migrate the data storage
    # for existing users
    supported_bookmark_types = ("content",)
    supported_follow_types = ("user", "content", "tag")
    supported_like_types = ("content", "update")
    supported_tag_types = ("user", "content")
    supported_watch_types = ("workspace",)

    def __init__(self, context=None):
        # Set up storage for personalized data structures.

        # BOOKMARK: users can bookmark content.
        # ---------------------------------------------------

        # _bookmarks["content"][userid] = (uuid, uuid, ...)
        # _bookmarked["content"][uuid] = (userid, userid, ...)

        # _bookmarked_on[userid][item_id] = datetime  # UTC

        # FOLLOW: users can follow eachother, or content etc.
        # ---------------------------------------------------

        # _following["user"][userid] = (userid, userid, ...)
        # _followers["user"][userid] = (userid, userid, ...)

        # Other follow types can be switched on here but
        # are not used anywhere yet.

        # LIKE: users can like content or statusupdates
        # ---------------------------------------------

        # _likes["content"][userid] = (uuid, uuid, ...)
        # _liked["content"][uuid] = (userid, userid, ...)

        # _likes["update"][userid] = (statusid, statusid, ...)
        # _liked["update"][statusid] = (userid, userid, ...)

        # TAG: users can apply personal tags on anything.
        # -----------------------------------------------

        # More complex than following or liking
        # since tagging is a 3-way relation (subject, tags, object)

        # Endorsements can be implemented as users tagging item_id users.

        # supported_tag_types = ("user", "content", "update")

        # Objects tagged by a user:
        # _tagged[userid] = {tag: {type: ids}}
        # _tagged[userid][tag] = {type: ids}
        # _tagged[userid][tag]["user"] = (userid, userid, ...)
        # _tagged[userid][tag]["content"] = (uuid, uuid, ...)

        # Users that tagged an object:
        # _tagger[item_type][id] = {tag: userids}
        # _tagger["user"][userid][tag] = (userid, userid, ...)
        # _tagger["content"][uuid][tag] = (userid, userid, ...)

        # Tags applied by a user:
        # _tags[user_id] = {item_type: item_id: (tag, tag,...)}
        # _tags[user_id]["user"][userid] = (tag, tag, ...)
        # _tags[user_id]["content"][uuid] = (tag, tag, ...)

        # Find objects by tag: (and the users who applied that tag)
        # _bytag[tag] = {item_type: {item_id: (userid, userid)}
        # _bytag[tag]["user"][user_id] = (userid, userid, ...)
        # _bytag[tag]["content"][uuid] = (userid, userid, ...)

        # WATCH: users can watch workspaces.
        # ---------------------------------------------------

        # _watching["workspace"][userid] = (workspaceid, workspaceid, ...)
        # _watchers["workspace"][workspaceid] = (userid, userid, ...)

        # bookmarks
        self._bookmarks = OOBTree.OOBTree()
        self._bookmarked = OOBTree.OOBTree()
        self._bookmarked_on = OOBTree.OOBTree()
        for item_type in self.supported_bookmark_types:
            self._bookmarks[item_type] = OOBTree.OOBTree()
            self._bookmarked[item_type] = OOBTree.OOBTree()

        # following
        self._following = OOBTree.OOBTree()
        self._followers = OOBTree.OOBTree()
        for item_type in self.supported_follow_types:
            self._following[item_type] = OOBTree.OOBTree()
            self._followers[item_type] = OOBTree.OOBTree()

        # like
        self._likes = OOBTree.OOBTree()
        self._liked = OOBTree.OOBTree()
        for item_type in self.supported_like_types:
            self._likes[item_type] = OOBTree.OOBTree()
            self._liked[item_type] = OOBTree.OOBTree()

        # tags
        self._tagged = OOBTree.OOBTree()
        self._tagger = OOBTree.OOBTree()
        self._tags = OOBTree.OOBTree()
        self._bytag = OOBTree.OOBTree()
        for item_type in self.supported_tag_types:
            self._tagger[item_type] = OOBTree.OOBTree()
        # more initialization on the fly in self.tag()

        # watching
        self._watching = OOBTree.OOBTree()
        self._watchers = OOBTree.OOBTree()
        for item_type in self.supported_watch_types:
            self._watching[item_type] = OOBTree.OOBTree()
            self._watchers[item_type] = OOBTree.OOBTree()

    # needed in suite/setuphandlers
    clear = __init__

    def _set_insert(self, storage, key, value):
        # Given a btreeish storage be sure storage[key]
        # is a OOTreeSet we can insert value in to
        # if key is in storage the following instruction will have no effect
        storage.insert(key, OOBTree.OOTreeSet())
        storage[key].insert(value)

    def _set_remove(self, storage, key):
        # Remove from OOTreeSet key if found
        key in storage and storage.remove(key)

    # bookmarks API
    def _bookmark_type_validator(self, item_type):
        item_type = decode(item_type)
        if item_type not in self.supported_bookmark_types:
            raise ValueError("Unsupported type: %r" % item_type)
        return item_type

    def _bookmark_user_validator(self, user_id):
        if not user_id:
            user_id = api.user.get_current().id
        user_id = decode(user_id)
        return user_id

    def bookmark(self, item_type, item_id, user_id=None):
        # User <user_id> bookmarks <item_type> <item_id>

        user_id = self._bookmark_user_validator(user_id)
        item_type = self._bookmark_type_validator(item_type)
        item_id = decode(item_id)

        self._set_insert(self._bookmarks[item_type], user_id, item_id)
        self._set_insert(self._bookmarked[item_type], item_id, user_id)

        # _set_insert is not valid since we need a OOBTree
        self._bookmarked_on.insert(user_id, OOBTree.OOBTree())
        # _set_remove is not valid on a OOBTree
        self._bookmarked_on[user_id][item_id] = datetime.now(pytz.utc)

    def unbookmark(self, item_type, item_id, user_id=None):
        # User <user_id> unbookmarks <item_type> <item_id>

        user_id = self._bookmark_user_validator(user_id)
        item_type = self._bookmark_type_validator(item_type)
        item_id = decode(item_id)
        self._set_remove(self._bookmarks[item_type][user_id], item_id)
        self._set_remove(self._bookmarked[item_type][item_id], user_id)

        self._bookmarked_on[user_id].pop(item_id)

    def get_bookmarks(self, item_type, user_id=None):
        # List all <item_type> bookmarked by <user_id>
        user_id = self._bookmark_user_validator(user_id)
        item_type = self._bookmark_type_validator(item_type)
        return self._bookmarks[item_type].get(user_id, [])

    def get_bookmarkers(self, item_type, item_id):
        # List all userids bookmarking <item_type> <item_id>

        item_type = self._bookmark_type_validator(item_type)
        item_id = decode(item_id)
        return self._bookmarked[item_type].get(item_id, [])

    def is_bookmarked(self, item_type, item_id, user_id=None):
        # Has <user_id> bookmarked <item_type> <item_id>?

        user_id = self._bookmark_user_validator(user_id)
        item_type = self._bookmark_type_validator(item_type)
        item_id = decode(item_id)
        return user_id in self.get_bookmarkers(item_type, item_id)

    is_bookmarking = is_bookmarked

    def bookmarked_on(self, item_id, user_id=None):
        # At which datetime (UTC) did <user_id> bookmark <item_id>?
        user_id = self._bookmark_user_validator(user_id)
        item_id = decode(item_id)
        return self._bookmarked_on[user_id][item_id]

    def get_bookmarks_by_date(self, item_type, user_id=None):
        # add bookmarking date to get_bookmarks
        # returns [ (bookmark.id, bookmark.datetime), ... ]
        Bookmark = namedtuple("Bookmark", "id, datetime")
        bookmarks = [
            Bookmark(id, self.bookmarked_on(id, user_id))
            for id in self.get_bookmarks(item_type, user_id)
        ]
        return sorted(bookmarks, key=lambda x: x.datetime)

    # following API

    def follow(self, item_type, item_id, user_id=None):
        # User <user_id> subscribes to <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_follow_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        # insert user if not exists
        self._following[item_type].insert(user_id, OOBTree.OOTreeSet())
        self._followers[item_type].insert(item_id, OOBTree.OOTreeSet())
        # add follow subscription
        self._following[item_type][user_id].insert(item_id)
        self._followers[item_type][item_id].insert(user_id)

    def unfollow(self, item_type, item_id, user_id=None):
        # User <user_id> unsubscribes from <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_follow_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        try:
            self._following[item_type][user_id].remove(item_id)
        except KeyError:
            pass
        try:
            self._followers[item_type][item_id].remove(user_id)
        except KeyError:
            pass

    def get_following(self, item_type, user_id=None):
        # List all <item_type> that <user_id> subscribes to
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_follow_types
        user_id = decode(user_id)
        try:
            return self._following[item_type][user_id]
        except KeyError:
            return ()

    def get_followers(self, item_type, item_id):
        # List all users that subscribe to <item_type> <item_id>
        item_type = decode(item_type)
        assert item_type in self.supported_follow_types
        item_id = decode(item_id)

        try:
            return self._followers[item_type][item_id]
        except KeyError:
            return ()

    def is_followed(self, item_type, item_id, user_id=None):
        # Does <user_id> follow <item_type> <item_id>?
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_follow_types
        user_id = decode(user_id)
        item_id = decode(item_id)

        try:
            return user_id in self.get_followers(item_type, item_id)
        except KeyError:
            return False

    is_following = is_followed

    # like API

    def like(self, item_type, item_id, user_id=None):
        # User <user_id> likes <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_like_types
        user_id = decode(user_id)
        item_id = decode(item_id)

        self._likes[item_type].insert(user_id, OOBTree.OOTreeSet())
        self._liked[item_type].insert(item_id, OOBTree.OOTreeSet())

        self._likes[item_type][user_id].insert(item_id)
        self._liked[item_type][item_id].insert(user_id)

    def unlike(self, item_type, item_id, user_id=None):
        # User <user_id> unlikes <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_like_types
        user_id = decode(user_id)
        item_id = decode(item_id)

        try:
            self._likes[item_type][user_id].remove(item_id)
        except KeyError:
            pass
        try:
            self._liked[item_type][item_id].remove(user_id)
        except KeyError:
            pass

    def get_likes(self, item_type, user_id=None):
        # List all <item_type> liked by <user_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_like_types
        user_id = decode(user_id)
        try:
            return self._likes[item_type][user_id]
        except KeyError:
            return []

    def get_likers(self, item_type, item_id):
        # List all userids liking <item_type> <item_id>
        item_type = decode(item_type)
        assert item_type in self.supported_like_types
        item_id = decode(item_id)
        try:
            return self._liked[item_type][item_id]
        except KeyError:
            return []

    def is_liked(self, item_type, item_id, user_id=None):
        # Does <user_id> like <item_type> <item_id>?
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_like_types
        user_id = decode(user_id)
        item_id = decode(item_id)

        try:
            return user_id in self.get_likers(item_type, item_id)
        except KeyError:
            return False

    is_liking = is_liked

    # tags API

    def tag(self, item_type, item_id, user_id, *tags):
        # User <user_id> adds tags <*tags> on <item_type> <item_id>
        item_type = decode(item_type)
        assert item_type in self.supported_tag_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        tags = [decode(tag) for tag in tags]

        for tag in tags:

            if user_id not in self._tagged:
                self._tagged[user_id] = OOBTree.OOBTree()
            if tag not in self._tagged[user_id]:
                self._tagged[user_id][tag] = OOBTree.OOBTree()
                for _type in self.supported_tag_types:
                    self._tagged[user_id][tag][_type] = OOBTree.OOTreeSet()

            self._tagged[user_id][tag][item_type].insert(item_id)

            if item_id not in self._tagger[item_type]:
                self._tagger[item_type][item_id] = OOBTree.OOBTree()
            if tag not in self._tagger[item_type][item_id]:
                self._tagger[item_type][item_id][tag] = OOBTree.OOTreeSet()

            self._tagger[item_type][item_id][tag].insert(user_id)

            if user_id not in self._tags:
                self._tags[user_id] = OOBTree.OOBTree()
                for _type in self.supported_tag_types:
                    self._tags[user_id][_type] = OOBTree.OOBTree()
            if item_id not in self._tags[user_id][item_type]:
                self._tags[user_id][item_type][item_id] = OOBTree.OOTreeSet()

            self._tags[user_id][item_type][item_id].insert(tag)

            if tag not in self._bytag:
                self._bytag[tag] = OOBTree.OOBTree()
                for _type in self.supported_tag_types:
                    self._bytag[tag][_type] = OOBTree.OOBTree()
            if item_id not in self._bytag[tag][item_type]:
                self._bytag[tag][item_type][item_id] = OOBTree.OOTreeSet()

            self._bytag[tag][item_type][item_id].insert(user_id)

    def untag(self, item_type, item_id, user_id, *tags):
        # User <user_id> removes tags <*tags> from <item_type> <item_id>
        item_type = decode(item_type)
        assert item_type in self.supported_tag_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        tags = [decode(tag) for tag in tags]
        for tag in tags:
            self._tagged[user_id][tag][item_type].remove(item_id)
            self._tagger[item_type][item_id][tag].remove(user_id)
            self._tags[user_id][item_type][item_id].remove(tag)
            self._bytag[tag][item_type][item_id].remove(user_id)

    def get_tagged(self, item_type=None, user_id=None, tag=None):
        # List <item_type> item_ids tagged as <tag> by <user_id>.
        #
        # If all parameters are given:
        #   returns [item_id, item_id]
        #
        # If one or more parameters are missing, the resulting
        # data structure differs as follows:
        #
        # If item_type==None:
        #   returns {item_type: [item_id..]}
        #
        # if userid==None:
        #   returns {objectid: [user_id..]}
        #
        # If tag==None:
        #   returns {tag: [item_id..]}
        #
        # If only item_type: # user_id==None and tag==None
        #   returns {user_id: {tag: [item_id..]}}
        #
        # If only user_id:  # item_type==None and tag==None
        #   returns {tag: {item_type: [item_id..]}}
        #
        # If only tag:  # item_type==None and user_id==None
        #   returns {item_type: {item_id: [user_id..]}}
        #
        # If all parameters are None:  # dumps the full data structure
        #   returns {user_id: {tag: {item_type: [item_id..]}}}
        #
        #
        # See the test_tags test suite for specific examples of all of the
        # possible return types.

        item_type = decode(item_type, True)
        assert item_type is None or item_type in self.supported_tag_types
        user_id = decode(user_id, True)
        tag = decode(tag, True)

        try:

            if item_type and user_id and tag:  # all
                return self._tagged[user_id][tag][item_type]

            # at this point at least one parameter is None

            elif user_id and tag:  # no item_type
                return self.unpack(self._tagged[user_id][tag])

            elif item_type and tag:  # no user_id
                return self.unpack(self._bytag[tag][item_type])

            elif item_type and user_id:  # no tag
                base = self.unpack(self._tagged[user_id])
                retval = {}
                # keep only the selected item_type
                # and remove item_type level in dict
                for tag in base:
                    retval[tag] = base[tag][item_type]
                return retval

            # at this point we are dealing with a single parameter
            # where the other two are set to None

            elif item_type:
                return self.unpack(self._tagger[item_type])

            elif user_id:
                return self.unpack(self._tagged[user_id])

            elif tag:
                return self.unpack(self._bytag[tag])

            # at this point all params are None

            else:
                return self.unpack(self._tagged)

        except KeyError:
            return ()

    def unpack(self, btreeish):
        # Helper method to convert BTrees and TreeSets to normal dict/list
        # structures. Supports recursive unpack.
        if btreeish is None:
            return []
        try:
            return {k: self.unpack(v) for (k, v) in btreeish.items()}
        except AttributeError:
            return [k for k in btreeish]

    def get_taggers(self, item_type, item_id, tag=None):
        # List user_ids that tagged <item_type> <item_id> with <tag>.
        # If tag==None: returns {tag: (itemids..)} mapping
        item_type = decode(item_type)
        assert item_type in self.supported_tag_types
        item_id = decode(item_id)
        tag = decode(tag, True)
        if tag:
            return self._tagger[item_type][item_id][tag]
        else:
            return self._tagger[item_type][item_id]

    def get_tags(self, item_type, item_id, user_id=None):
        # List tags set on <item_type> <item_id> by <user_id>.
        # If user_id==None: return {tag: (userids..)} mapping
        item_type = decode(item_type)
        assert item_type in self.supported_tag_types
        item_id = decode(item_id)
        user_id = decode(user_id, True)
        if user_id:
            return self._tags[user_id][item_type][item_id]
        else:
            return self._tagger[item_type][item_id]

    def is_tagged(self, item_type, item_id, user_id, tag):
        # Did <user_id> apply tag <tag> on <item_type> <item_id>?
        item_type = decode(item_type)
        item_id = decode(item_id)
        user_id = decode(user_id)
        tag = decode(tag)
        assert item_type in self.supported_tag_types
        try:
            return user_id in self.get_taggers(item_type, item_id, tag)
        except KeyError:
            return False

    # watch API

    def watch(self, item_type, item_id, user_id=None):
        # User <user_id> subscribes to <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_watch_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        # insert user if not exists
        self._watching[item_type].insert(user_id, OOBTree.OOTreeSet())
        self._watchers[item_type].insert(item_id, OOBTree.OOTreeSet())
        # add watch subscription
        self._watching[item_type][user_id].insert(item_id)
        self._watchers[item_type][item_id].insert(user_id)

    def unwatch(self, item_type, item_id, user_id=None):
        # User <user_id> unsubscribes from <item_type> <item_id>
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_watch_types
        user_id = decode(user_id)
        item_id = decode(item_id)
        try:
            self._watching[item_type][user_id].remove(item_id)
        except KeyError:
            pass
        try:
            self._watchers[item_type][item_id].remove(user_id)
        except KeyError:
            pass

    def get_watching(self, item_type, user_id=None):
        # List all <item_type> that <user_id> subscribes to
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_watch_types
        user_id = decode(user_id)
        try:
            return self._watching[item_type][user_id]
        except KeyError:
            return ()

    def get_watchers(self, item_type, item_id):
        # List all users that subscribe to <item_type> <item_id>
        item_type = decode(item_type)
        assert item_type in self.supported_watch_types
        item_id = decode(item_id)

        try:
            return self._watchers[item_type][item_id]
        except KeyError:
            return ()

    def is_watched(self, item_type, item_id, user_id=None):
        # Does <user_id> watch <item_type> <item_id>?
        if not user_id:
            user_id = api.user.get_current().id
        item_type = decode(item_type)
        assert item_type in self.supported_watch_types
        user_id = decode(user_id)
        item_id = decode(item_id)

        try:
            return user_id in self.get_watchers(item_type, item_id)
        except KeyError:
            return False

    is_watching = is_watched
