from AccessControl import Unauthorized
from copy import copy
from importlib.resources import files
from plone import api
from plone.app.blocks.interfaces import IBlocksTransformEnabled
from plone.memoize.view import memoize
from plone.memoize.view import memoize_contextless
from plone.namedfile.file import NamedBlobImage
from plone.protect.utils import safeWrite
from ploneintranet import api as pi_api
from ploneintranet.core import _
from ploneintranet.layout.interfaces import IDiazoAppTemplate
from ploneintranet.network.interfaces import INetworkTool
from ploneintranet.search.interfaces import ISiteSearch
from ploneintranet.userprofile.browser.forms import get_fields_for_template
from ploneintranet.userprofile.browser.forms import UserProfileViewForm
from ploneintranet.userprofile.content.userprofile import UserProfile
from ploneintranet.workspace.adapters import AVAILABLE_GROUPS
from ploneintranet.workspace.config import INTRANET_USERS_GROUP_ID
from Products.CMFCore.permissions import ModifyPortalContent
from Products.CMFPlone.browser.author import AuthorView as BaseAuthorView
from Products.Five import BrowserView
from Products.PlonePAS.utils import scale_image
from zExceptions import NotFound
from zope.component import getUtility
from zope.datetime import rfc1123_date
from zope.deprecation import deprecate
from zope.event import notify
from zope.interface import implementer
from zope.lifecycleevent import ObjectModifiedEvent
from zope.publisher.interfaces import IPublishTraverse

import logging
import requests

AVATAR_SIZES = {"profile": 200, "stream": 50}


def default_avatar(response):
    """Return the contents of a default profile image"""
    img_data = (
        files("ploneintranet.userprofile.browser") / "defaultUser-168.png"
    ).read_bytes()
    response.setHeader("content-type", "image/png")
    response.setHeader("content-disposition", 'inline; filename="DefaultAvatar.png"')
    response.setHeader("content-length", len(img_data))
    return img_data


@implementer(IDiazoAppTemplate, IBlocksTransformEnabled)
class UserProfileView(UserProfileViewForm):
    """View for user profile."""

    @property
    @memoize_contextless
    def app(self):
        return api.portal.get().apps.get("contacts")

    @property
    @memoize_contextless
    def back_to_group(self):
        groupid = self.request.get("back_to_group", None)
        if groupid:
            group = pi_api.workgroup.get(groupid)
            return group

    @property
    @memoize_contextless
    def back_to_url(self):
        if self.back_to_group:
            return f"{self.back_to_group.absolute_url()}#document-body"
        return f"{self.app.absolute_url()}/@@all-users#document-body"

    @property
    @memoize_contextless
    def back_to_title(self):
        if self.back_to_group:
            return self.back_to_group.title
        return _("label_all_users", default="All users")

    @property
    @memoize
    def tabs(self):
        return self.context.restrictedTraverse("@@plone_context_state").actions(
            "userprofile_tabs"
        )

    @property
    @memoize
    def allowed_tabs(self):
        """Return the list of allowed tabs for this user profile"""
        return [tab["id"] for tab in self.tabs]

    @property
    @memoize
    def display_tabs(self):
        """Check if the navigation should be displayed"""
        return len(self.allowed_tabs) > 1

    @property
    @memoize
    def default_tab(self):
        """Check if the navigation should be displayed"""
        allowed_tabs = self.allowed_tabs
        if not allowed_tabs:
            return ""
        else:
            return self.__name__

    @property
    @memoize
    def display_followers(self):
        """Check if we should display the followers information"""
        return "userprofile-followers" in self.allowed_tabs

    @property
    @memoize
    def display_following(self):
        """Check if we should display the following information"""
        return "userprofile-following" in self.allowed_tabs

    def get_tab_css_classes(self, tab):
        """Return the CSS classes for the given tab"""
        classes = ["pat-inject", f"userprofile-tab-{tab['id']}"]
        if tab["id"] == self.default_tab:
            classes.append("current")
        elif tab["id"] == "userprofile-info" and self.default_tab == "userprofile-view":
            classes.append("current")
        return " ".join(classes)

    @property
    @memoize
    def display_more_info_link(self):
        """The more information link does not make sense if the only
        tab available is the userprofile-info or if userprofile-info is not
        between the allowed tabs
        """
        return self.display_tabs and "userprofile-info" in self.allowed_tabs

    @property
    @memoize
    def display_change_personal_image(self):
        """if portrait is not listed in read_only_fields
        we do not display the form to change it
        """
        ro_fields = api.portal.get_registry_record(
            "ploneintranet.userprofile.read_only_fields", default=()
        )
        return "portrait" not in ro_fields and self.can_edit()

    @memoize
    def is_ajax(self):
        """Check if we have an ajax call"""
        requested_with = self.request.environ.get("HTTP_X_REQUESTED_WITH")
        return requested_with == "XMLHttpRequest"

    def disable_diazo(self):
        """Disable diazo if this is an ajax call"""
        self.request.response.setHeader("X-Theme-Disabled", "1")

    @property
    @memoize_contextless
    def pas_view(self):
        """The graph containing the relations between the principals
        managed by PAS
        """
        return api.content.get_view("pas_view", self.context, self.request)

    @property
    @memoize
    def ancestorspasuids(self):
        """Return the PAS graph uids of the ancestors this user has"""
        pasuid = f"userprofile:{self.context.UID()}"
        if (
            api.portal.get_registry_record(
                "ploneintranet.userprofile.extranet_enabled", default=False
            )
            is False
        ):
            banned_ancestors = {
                self.pas_view.name2uid.get(f"workgroup:{INTRANET_USERS_GROUP_ID}")
            }
        else:
            banned_ancestors = set()
        new_uids_sets = ancestors = (
            self.pas_view.predecessors(pasuid) - banned_ancestors
        )
        while new_uids_sets:
            new_uids_sets = (
                self.pas_view.predecessors_for_uids(new_uids_sets)
                - ancestors
                - banned_ancestors
            )
            ancestors.update(new_uids_sets)
        return ancestors

    @property
    @memoize
    def grouppasuids(self):
        """Return the PAS graph uids of the groups this user is member of"""
        groups = {
            uid for uid in self.ancestorspasuids if uid.partition(":")[0] == "workgroup"
        }
        return groups

    @property
    @memoize
    def workspacepasuids(self):
        """Return the PAS graph uids of the workspaces this user is member of"""
        result = set()
        workspaces = self.portal.get("workspaces")
        if not workspaces:
            return result
        # use this to filter out templates
        allowed_plonepath = "/".join(workspaces.getPhysicalPath())
        uid2path = self.pas_view.uid2path

        # mark as seen the uids of the userspaces (we do not count them)
        brains = api.content.find(portal_type="ploneintranet.workspace.superspace")
        seen_ploneuids = {brain.UID for brain in brains}
        for pasuid in self.ancestorspasuids:
            ploneuid = pasuid.rpartition(":")[-1]
            if ploneuid not in seen_ploneuids:
                # Given we have multiple groups for every workspaces
                # we consider just the first one
                seen_ploneuids.add(ploneuid)
                plonepath = uid2path[pasuid].rpartition(":")[-1]
                if plonepath.startswith(allowed_plonepath):
                    result.add(pasuid)

        include_archived = self.request.get("archived", False)
        if include_archived:
            return result

        sitesearch = getUtility(ISiteSearch)
        solr_result = sitesearch.query(
            query=(
                sitesearch.Q(is_archived=True)
                & sitesearch.lucene_or(
                    [sitesearch.Q(UID=res.rpartition(":")[-1]) for res in result]
                )
            ),
            step=len(result),
        )
        archived_uids = [r.UID for r in solr_result]
        result = {res for res in result if res.rpartition(":")[-1] not in archived_uids}
        return result

    @property
    @memoize
    def my_groups(self):
        """Return the attribute _my_groups,
        if needed invoke the function _get_my_groups_and_workspaces to set it
        """
        try:
            return sorted(
                map(self.pas_view.resolvepasuid, self.grouppasuids),
                key=lambda obj: obj.Title(),
            )
        except KeyError:
            pass
        try:
            return self._my_groups
        except AttributeError:
            self._get_my_groups_and_workspaces()
        return self._my_groups

    @property
    @memoize
    def my_workspaces(self):
        """Return the attribute _my_groups,
        if needed invoke the function _get_my_groups_and_workspaces to set it
        """
        try:
            return sorted(
                map(self.pas_view.resolvepasuid, self.workspacepasuids),
                key=lambda obj: obj.Title(),
            )
        except KeyError:
            pass
        try:
            return self._my_workspaces
        except AttributeError:
            self._get_my_groups_and_workspaces()
        return self._my_workspaces

    @property
    @memoize
    def num_groups(self):
        """Return the number of groups this user is member of"""
        try:
            return len(self.grouppasuids)
        except KeyError:
            return len(self.my_groups)

    @property
    @memoize
    def num_workspaces(self):
        """Return the number of workspaces this user is member of"""
        try:
            return len(self.workspacepasuids)
        except KeyError:
            return len(self.my_workspaces)

    def update(self):
        if self.__name__ not in ("userprofile-info", "userprofile-view"):
            return
        self._update_recent_contacts()

    def is_me(self):
        """Does this user profile belong to the current user"""
        # .username is actually the userid see #1043
        return self.context.username == api.user.get_current().getId()

    def can_edit(self):
        """Check current user permissions to edit this object"""
        if self.request.form.get("skip_edit"):
            return False

        return api.user.has_permission(ModifyPortalContent, obj=self.context)

    @memoize_contextless
    def is_displayed(self, principal: UserProfile) -> bool:
        """This is used to check if a userprofile should be displayed in the UI,
        e.g. among the followers of a user.
        """
        excluded_review_states = (
            api.portal.get_registry_record(
                "ploneintranet.userprofile.ui.excluded_review_states",
                default=("disabled", "pending"),
            )
            or ()
        )
        return api.content.get_state(obj=principal) not in excluded_review_states

    @memoize
    def following(self) -> list[UserProfile]:
        """Users this profile is following"""
        graph = getUtility(INetworkTool)
        return list(
            filter(
                self.is_displayed,
                self._user_details(graph.get_following("user", self.context.username)),
            )
        )

    @memoize
    def followers(self) -> list[UserProfile]:
        """Users who are following this profile"""
        graph = getUtility(INetworkTool)
        return list(
            filter(
                self.is_displayed,
                self._user_details(graph.get_followers("user", self.context.username)),
            )
        )

    @deprecate("Deprecated in 6.3.1.dev0. Use is_displayed instead.")
    def is_enabled(self, principal):
        """This is used to check if a userprofile should be displayed."""
        return self.is_displayed(principal)

    @property
    @memoize_contextless
    def portal(self):
        """Returns the portal object"""
        return api.portal.get()

    @property
    @memoize_contextless
    def group_container(self):
        """Returns the group_container or an empty dict"""
        return self.portal.get("groups", {})

    @property
    @memoize_contextless
    def workspace_container(self):
        """The main workspace container"""
        return self.portal.get("workspaces", {})

    @property
    @memoize_contextless
    def workspace_container_view(self):
        """The main workspace container view"""
        return api.content.get_view(
            "workspaces.html", self.workspace_container, self.request
        )

    @property
    @memoize
    def group_ids(self):
        pg = api.portal.get_tool("portal_groups")
        user = api.user.get(username=self.context.username)
        # Filter out some trivial groups
        my_groupids = {
            groupid
            for groupid in pg.getGroupsForPrincipal(user)
            if groupid not in {"Members", "AuthenticatedUsers"}
        }
        return my_groupids

    def _get_my_groups_and_workspaces(self):
        """
        Find all the groups and all the workspaces the user is a member of.
        Since workspaces can also act as groups, only count those items
        as groups which are not also a workspace.
        """
        my_groupids = self.group_ids
        workspace_uids = set()
        groups = []
        group_container = self.group_container

        # Remove the collective.workspace groups
        for groupid in list(my_groupids):
            group, uid = groupid.partition(":")[::2]
            if group in AVAILABLE_GROUPS and len(uid) >= 32:
                workspace_uids.add(uid)
                my_groupids.remove(groupid)

        mt = api.portal.get_tool("membrane_tool")
        workspaces_path = "/".join(self.workspace_container.getPhysicalPath())
        groups_path = "/".join(group_container.getPhysicalPath())
        brains = mt(
            exact_getGroupId=list(my_groupids), path=[workspaces_path, groups_path]
        )
        for brain in brains:
            brain_path = brain.getPath()
            if brain_path.startswith(workspaces_path):
                workspace_uids.add(brain.UID)
                my_groupids.remove(brain.getId)
            else:
                group = brain.getObject()
                groups.append(group)
                my_groupids.remove(group.getGroupId())

        self._my_groups = groups
        # Keep only the workspace that are in the right place
        self._my_workspaces = [
            brain
            for brain in self.portal.portal_catalog(
                path="/".join(self.workspace_container.getPhysicalPath()),
                UID=list(workspace_uids),
                is_archived=False,
            )
            if "superspace" not in brain.portal_type
        ]

    def _user_details(self, userids):
        """User profiles for the given userids"""
        details = pi_api.userprofile.get_users(
            exact_getUserId=list(userids), review_state="enabled"
        )
        return list(details)

    def _update_recent_contacts(self):
        """Update, if needed, the list of the last twenty profiles
        that we have visited
        """
        my_profile = pi_api.userprofile.get_current()
        contact = self.context.username
        if not my_profile or my_profile.username == contact:
            return
        recent_contacts = copy(my_profile.recent_contacts or [])

        # If the contact is already the first on the list, we have nothing todo
        try:
            if recent_contacts.index(contact) == 0:
                return
        except ValueError:
            pass

        # Otherwise we want it to be the first on the list
        try:
            recent_contacts.remove(contact)
        except ValueError:
            pass
        recent_contacts.insert(0, contact)

        # We limit ourselves
        recent_contacts = recent_contacts[:20]

        # Do not touch the DB if nothing has changed
        if my_profile.recent_contacts == recent_contacts:
            return
        safeWrite(my_profile, self.request)
        my_profile.recent_contacts = recent_contacts

    def fields_for_display(self):
        return get_fields_for_template(self)

    @memoize
    def user_search_placeholder(self):
        msg = _(
            "user_search_placeholder",
            default="Search ${user_name}'s documents",
            mapping={"user_name": self.context.fullname},
        )
        return msg

    def get_avatar_tag(self):
        """Bypass the proto ram cache when editing the avatar"""
        return pi_api.userprofile.avatar_tag(self.context.getId(), link_to="image")

    def __call__(self):
        """Handle avatar edit, or render"""
        if self.request.get("submit"):
            # handle the avatar edit change

            portrait = self.request.form.get("portrait")
            if portrait and portrait.filename:
                old_timestamp = self.context.modified().ISO()

                scaled, mimetype = scale_image(portrait)
                image = NamedBlobImage(data=scaled.read(), filename=portrait.filename)
                self.context.portrait = image
                notify(ObjectModifiedEvent(self.context))

                # purge varnish
                target_url = "{profile_url}/@@avatar_profile.jpg?ts={timestamp}".format(
                    profile_url=self.context.absolute_url(), timestamp=old_timestamp
                )
                try:
                    requests.request("PURGE", target_url, verify=False, timeout=3)
                except (
                    requests.exceptions.RequestException,
                    requests.exceptions.SSLError,
                ) as e:
                    # Attempt to purge failed. Log and continue.
                    logging.exception(e)
        return super().__call__()


class UserProfileTabView(UserProfileView):
    """Personalize the userprofile tab view class to not be transformed
    by diazo if we have an ajax call
    """

    def __call__(self):
        """Set diazo.off if this is an ajax request"""
        if self.is_ajax():
            self.disable_diazo()
        return super().__call__()


class UserProfileTabFollowersView(UserProfileTabView):
    """Customization for userprofile-followers."""

    def available(self):
        """True, if tab should be shown."""
        return len(self.followers()) > 0

    def label(self):
        """Return the tab title for the followers user profile tab."""
        num_followers = len(self.followers())
        label = api.portal.translate(
            _(
                "Followers ${num_followers}",
                mapping={"num_followers": f"({num_followers})"},
            )
        )
        return label


class UserProfileTabFollowingView(UserProfileTabView):
    """Customization for userprofile-following."""

    def available(self):
        """True, if tab should be shown."""
        return len(self.following()) > 0

    def label(self):
        """Return the tab title for the following user profile tab."""
        num_following = len(self.following())
        label = api.portal.translate(
            _(
                "Following ${num_following}",
                mapping={"num_following": f"({num_following})"},
            )
        )
        return label


class AuthorView(BaseAuthorView):
    """Overrides default author view to link to PI profiles"""

    def __call__(self):
        profile = pi_api.userprofile.get(self.username)

        if profile is not None:
            return self.request.response.redirect(profile.absolute_url())
        raise NotFound


class MyProfileView(BrowserView):
    """Helper view to redirect to current user's profile page"""

    def __call__(self):
        profile = pi_api.userprofile.get_current()

        if profile is not None:
            return self.request.response.redirect(profile.absolute_url())
        raise Unauthorized


def stream_avatar_data(profile, size, request):
    """Generate avatar at the specified size and stream it

    This is a utility method used by the browser views below.
    """
    response = request.response

    if not profile:
        return default_avatar(response)
    imaging = api.content.get_view(request=request, context=profile, name="images")

    if size not in AVATAR_SIZES:
        return default_avatar(response)

    width = height = AVATAR_SIZES.get(size)

    try:
        scale = imaging.scale(
            fieldname="portrait", width=width, height=height, direction="down"
        )
    except TypeError:
        # No image found
        return default_avatar(response)

    if scale is not None:
        data = scale.data
        mtime = rfc1123_date(profile._p_mtime)
        response.setHeader("Last-Modified", mtime)
        from plone.namedfile.utils import set_headers
        from plone.namedfile.utils import stream_data

        set_headers(data, response)
        return stream_data(data)
    else:
        return default_avatar(response)


@implementer(IPublishTraverse)
class AvatarsView(BrowserView):
    """Helper view to render a user's avatar image

    This view is designed to mimic Plone's default portrait setup.
    Where portraits are accessed via:
    /plone/portal_memberdata/portraits/userid
    this can be replaced with:
    /plone/@@avatars/userid

    This allows you to easily link to an avatar without first
    looking up the user profile object.
    """

    def publishTraverse(self, request, name):
        # @@avatars/userid/size
        self.userid = name

        stack = request.get("TraversalRequestNameStack", [])
        if stack:
            self.size = stack.pop()
        else:
            self.size = "stream"

        request["TraversalRequestNameStack"] = []
        return self

    def __call__(self):
        profile = pi_api.userprofile.get(self.userid)
        return stream_avatar_data(profile, self.size, self.request)


class MyAvatar(BrowserView):
    """Helper view to render a user's avatar image

    This view is designed to be used on the end of a user profile URL,
    e.g. in search results or listings

    /path/to/profile/avatar.jpg
    """

    def __call__(self):
        return stream_avatar_data(self.context, "stream", self.request)

    def avatar_profile(self):
        return stream_avatar_data(self.context, "profile", self.request)
