from copy import deepcopy
from server.exceptions import (
    ProjectNotFound,
    UserNotFound,
    UserNotInvolved,
    NotProjectLeader,
    AlreadyMemberOf,
    ProjectFull,
    CannotKickYourself,
)

from bson import ObjectId
from flask import Flask
from pymongo.database import Database

from server.exceptions import (
    AlreadyMemberOf,
    CannotKickYourself,
    NotProjectLeader,
    ProjectFull,
    ProjectNotFound,
    UserNotFound,
    UserNotInvolved,
)
from server.managers.notification_manager import NotificationManager
from server.models.project import Project

# Common pipeline for listing projects. Joins userids with the Users table while
# omitting their email and password fields.
_list_pipeline = [
    {
        "$lookup": {
            "from": "users",
            "localField": "leader",
            "foreignField": "_id",
            "as": "leader",
        }
    },
    {
        "$lookup": {
            "from": "users",
            "localField": "members",
            "foreignField": "_id",
            "as": "members",
        }
    },
    {"$unwind": "$leader"},
    # omit unnecessary information about users
    {
        "$project": {
            "leader.password": 0,
            "members.password": 0,
            "leader.email": 0,
            "members.email": 0,
        }
    },
]


class ProjectManager:
    def __init__(self, app: Flask, db: Database, nm: NotificationManager):
        self.app = app
        self.nm = nm

        self.projects = db.get_collection("projects")
        self.users = db.get_collection("users")
        self.invitations = db.get_collection("invitations")
        self.requests = db.get_collection("join_requests")
        self.favourites = db.get_collection("favourites")

    def get_project(self, id: ObjectId):
        pipeline = deepcopy(_list_pipeline)
        pipeline.insert(0, {"$match": {"_id": id}})
        for doc in self.projects.aggregate(pipeline):
            return doc

    def get_project_listing(self, user_id: str = None):
        ret = []

        pipeline = deepcopy(_list_pipeline)

        # check if getting a project list for the current user or in general
        if user_id:
            pipeline.insert(0, {"$match": {"members": ObjectId(user_id)}})

        for doc in self.projects.aggregate(pipeline):
            ret.append(doc)

        return ret

    def search_project_listing(
        self,
        title: str = None,
        courses: list = None,
        languages: list = None,
        programming_languages: list = None,
        group_crit: str = None,
    ):
        ret = []

        # create queries
        q_title = None
        if title:
            q_title = {"title": {"$regex": title, "$options": "i"}}

        q_courses = None
        if courses:
            q_courses = {"course": {"$in": list(map(lambda x: x.upper(), courses))}}

        q_languages = None
        if languages:
            q_languages = {
                "languages": {"$in": list(map(lambda x: x.title(), languages))}
            }

        q_programming_languages = None
        if programming_languages:
            q_programming_languages = {
                "technologies": {
                    "$in": list(map(lambda x: x.title(), programming_languages))
                }
            }

        # check if we are doing a union or disjoin query
        q_group_crit = None
        if group_crit:
            if group_crit.lower() == "true":
                q_group_crit = True

        # check if there is any criteria to search for
        param_list = [q_title, q_courses, q_languages, q_programming_languages]
        q_list = []
        for q in param_list:
            if q != None:
                q_list.append(q)

        pipeline = deepcopy(_list_pipeline)

        # check if search criterias are grouped or disjoint
        if q_group_crit:
            # union search (i.e. AND query has to satisfy all of the criterias)
            if q_list:
                pipeline.insert(0, {"$match": {"$and": q_list}})
        else:
            # disjoint search (i.e. OR query has to satisfy any of the criterias)
            if q_list:
                pipeline.insert(0, {"$match": {"$or": q_list}})

        # append documents of search results to return list
        for doc in self.projects.aggregate(pipeline):
            ret.append(doc)

        return ret

    def delete_project(self, project_id: ObjectId):
        """
        Removes the project and all its references from the database.
        """
        self.invitations.delete_many({"project_id": project_id})
        self.requests.delete_many({"project_id": project_id})
        project = self.projects.find_one_and_delete({"_id": project_id})

        self.nm.notify_project_deleted(project["title"], project["members"])

    def update_project(self, project: Project, updated_details: dict):
        self.projects.update(
            {"_id": project["_id"]}, {"$set": updated_details}, upsert=False
        )

    def add_user_to_project(self, user_id: ObjectId, project_id: ObjectId):
        # TODO: this operation should be atomic.

        # check if project exists at all
        project = self.projects.find_one({"_id": project_id})
        if project is None:
            raise ProjectNotFound()

        # check if user exist all all
        user = self.users.find_one({"_id": user_id})
        if user is None:
            raise UserNotFound()

        # check if new user is already a member of the project group
        if user_id in project["members"]:
            raise AlreadyMemberOf()

        # check if project group has full members
        if len(project["members"]) == project["max_people"]:
            raise ProjectFull()

        # update database with new member data
        project["members"].append(user_id)
        project["cur_people"] = len(project["members"])

        self.projects.replace_one({"_id": project_id}, project)

        self.nm.notify_user_joined_project(
            user_id, user["username"], project["title"], project_id, project["members"]
        )

    def kick_user_from_project(
        self, user_id: ObjectId, leader_id: ObjectId, project_id: ObjectId
    ):
        # TODO: this operation should be atomic.
        # find the project to kick the member from
        project = self.projects.find_one({"_id": project_id})
        if project is None:
            raise ProjectNotFound()

        # check if user exists and if they are even in the project
        user = self.users.find_one({"_id": user_id})
        if user is None:
            raise UserNotFound()
        elif user["_id"] not in project["members"]:
            raise UserNotInvolved()

        # check if the person kicking the member is the leader
        if leader_id != project["leader"]:
            raise NotProjectLeader()
        elif user_id == leader_id:
            raise CannotKickYourself()

        # remove user from list
        old_members = list(project["members"])
        project["members"].remove(ObjectId(user_id))
        project["cur_people"] = len(project["members"])

        # update database with current members
        self.projects.replace_one({"_id": project_id}, project)

        self.nm.notify_user_kicked(
            user_id, user["username"], project["title"], project_id, old_members
        )

    def remove_invitation_request(self, user_id: ObjectId, project_id: ObjectId):
        """
        Removes all invitations or requests pertaining to the user and project.
        """
        self.invitations.delete_one({"user_id": user_id, "project_id": project_id})
        self.requests.delete_one({"user_id": user_id, "project_id": project_id})

    def remove_project_application(
        self, user: ObjectId, project_id: ObjectId, target_user: ObjectId
    ):
        # Check if the user is owner of the project
        project = self.projects.find_one({"_id": project_id})
        if project is None:
            raise ProjectNotFound
        if project["leader"] != user:
            raise NotProjectLeader

        result = self.requests.delete_one(
            {"project_id": project_id, "user_id": target_user}
        )
        if result.deleted_count == 0:
            raise ProjectNotFound

    def is_request_exist(self, user_id: ObjectId, project_id: ObjectId) -> bool:
        n = self.requests.count_documents(
            {"user_id": user_id, "project_id": project_id}
        )
        return n == 1

    def is_invitation_exist(self, user_id: ObjectId, project_id: ObjectId) -> bool:
        n = self.invitations.count_documents(
            {"user_id": user_id, "project_id": project_id}
        )
        return n == 1
