import json
import logging
from typing import Dict, List, Optional, Union

import requests
import urllib3
from frozendict import frozendict

from .apiobject import Organization, Repository, Team, User
from .exceptions import (
    AlreadyExistsException,
    ConflictException,
    NotFoundException,
    NotYetGeneratedException,
)
from .ratelimiter import RateLimitedSession


class AllSpice:
    """Object to establish a session with AllSpice Hub."""

    ADMIN_CREATE_USER = """/admin/users"""
    GET_USERS_ADMIN = """/admin/users"""
    ADMIN_REPO_CREATE = """/admin/users/%s/repos"""  # <ownername>
    ALLSPICE_HUB_VERSION = """/version"""
    GET_USER = """/user"""
    GET_REPOSITORY = """/repos/{owner}/{name}"""
    CREATE_ORG = """/admin/users/%s/orgs"""  # <username>
    CREATE_TEAM = """/orgs/%s/teams"""  # <orgname>

    def __init__(
        self,
        allspice_hub_url="https://hub.allspice.io",
        token_text=None,
        auth=None,
        verify=True,
        log_level="INFO",
        ratelimiting=(100, 60),
    ):
        """Initializing an instance of the AllSpice Hub Client

        Args:
            allspice_hub_url (str): The URL for the AllSpice Hub instance.
                Defaults to `https://hub.allspice.io`.

            token_text (str, None): The access token, by default None.

            auth (tuple, None): The user credentials
                `(username, password)`, by default None.

            verify (bool): If True, allow insecure server connections
                when using SSL.

            log_level (str): The log level, by default `INFO`.

            ratelimiting (tuple[int, int], None): `(max_calls, period)`,
                If None, no rate limiting is applied. By default, 100 calls
                per minute are allowed.
        """

        self.logger = logging.getLogger(__name__)
        self.logger.setLevel(log_level)
        self.headers = {
            "Content-type": "application/json",
        }
        self.url = allspice_hub_url

        if ratelimiting is None:
            self.requests = requests.Session()
        else:
            (max_calls, period) = ratelimiting
            self.requests = RateLimitedSession(max_calls=max_calls, period=period)

        # Manage authentification
        if not token_text and not auth:
            raise ValueError("Please provide auth or token_text, but not both")
        if token_text:
            self.headers["Authorization"] = "token " + token_text
        if auth:
            self.logger.warning(
                "Using basic auth is not recommended. Prefer using a token instead."
            )
            self.requests.auth = auth

        # Manage SSL certification verification
        self.requests.verify = verify
        if not verify:
            urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    def __get_url(self, endpoint):
        url = self.url + "/api/v1" + endpoint
        self.logger.debug("Url: %s" % url)
        return url

    def __get(self, endpoint: str, params=frozendict()) -> requests.Response:
        request = self.requests.get(self.__get_url(endpoint), headers=self.headers, params=params)
        if request.status_code not in [200, 201]:
            message = f"Received status code: {request.status_code} ({request.url})"
            if request.status_code in [404]:
                raise NotFoundException(message)
            if request.status_code in [403]:
                raise Exception(
                    f"Unauthorized: {request.url} - Check your permissions and try again! ({message})"
                )
            if request.status_code in [409]:
                raise ConflictException(message)
            if request.status_code in [503]:
                raise NotYetGeneratedException(message)
            raise Exception(message)
        return request

    @staticmethod
    def parse_result(result) -> Dict:
        """Parses the result-JSON to a dict."""
        if result.text and len(result.text) > 3:
            return json.loads(result.text)
        return {}

    def requests_get(self, endpoint: str, params=frozendict(), sudo=None):
        combined_params = {}
        combined_params.update(params)
        if sudo:
            combined_params["sudo"] = sudo.username
        return self.parse_result(self.__get(endpoint, combined_params))

    def requests_get_raw(self, endpoint: str, params=frozendict(), sudo=None) -> bytes:
        combined_params = {}
        combined_params.update(params)
        if sudo:
            combined_params["sudo"] = sudo.username
        return self.__get(endpoint, combined_params).content

    def requests_get_paginated(
        self,
        endpoint: str,
        params=frozendict(),
        sudo=None,
        page_key: str = "page",
        first_page: int = 1,
    ):
        page = first_page
        combined_params = {}
        combined_params.update(params)
        aggregated_result = []
        while True:
            combined_params[page_key] = page
            result = self.requests_get(endpoint, combined_params, sudo)

            if not result:
                return aggregated_result

            if isinstance(result, dict):
                if "data" in result:
                    data = result["data"]
                    if len(data) == 0:
                        return aggregated_result
                    aggregated_result.extend(data)
                else:
                    raise NotImplementedError(
                        "requests_get_paginated does not know how to handle responses of this type."
                    )
            else:
                aggregated_result.extend(result)

            page += 1

    def requests_put(self, endpoint: str, data: Optional[dict] = None):
        if not data:
            data = {}
        request = self.requests.put(
            self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
        )
        if request.status_code not in [200, 204]:
            message = f"Received status code: {request.status_code} ({request.url}) {request.text}"
            self.logger.error(message)
            raise Exception(message)

    def requests_delete(self, endpoint: str, data: Optional[dict] = None):
        request = self.requests.delete(
            self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
        )
        if request.status_code not in [200, 204]:
            message = f"Received status code: {request.status_code} ({request.url})"
            self.logger.error(message)
            raise Exception(message)

    def requests_post(
        self,
        endpoint: str,
        data: Optional[dict] = None,
        params: Optional[dict] = None,
        files: Optional[dict] = None,
    ):
        """
        Make a POST call to the endpoint.

        :param endpoint: The path to the endpoint
        :param data: A dictionary for JSON data
        :param params: A dictionary of query params
        :param files: A dictionary of files, see requests.post. Using both files and data
                      can lead to unexpected results!
        :return: The JSON response parsed as a dict
        """

        args = {
            "headers": self.headers.copy(),
        }
        if data is not None:
            args["data"] = json.dumps(data)
        if params is not None:
            args["params"] = params
        if files is not None:
            args["headers"].pop("Content-type")
            args["files"] = files

        request = self.requests.post(self.__get_url(endpoint), **args)

        if request.status_code not in [200, 201, 202]:
            if "already exists" in request.text or "e-mail already in use" in request.text:
                self.logger.warning(request.text)
                raise AlreadyExistsException()
            self.logger.error(f"Received status code: {request.status_code} ({request.url})")
            self.logger.error(f"With info: {data} ({self.headers})")
            self.logger.error(f"Answer: {request.text}")
            raise Exception(
                f"Received status code: {request.status_code} ({request.url}), {request.text}"
            )
        return self.parse_result(request)

    def requests_patch(self, endpoint: str, data: dict):
        request = self.requests.patch(
            self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
        )
        if request.status_code not in [200, 201]:
            error_message = f"Received status code: {request.status_code} ({request.url}) {data}"
            self.logger.error(error_message)
            raise Exception(error_message)
        return self.parse_result(request)

    def get_orgs_public_members_all(self, orgname):
        path = "/orgs/" + orgname + "/public_members"
        return self.requests_get(path)

    def get_orgs(self):
        path = "/admin/orgs"
        results = self.requests_get(path)
        return [Organization.parse_response(self, result) for result in results]

    def get_user(self):
        result = self.requests_get(AllSpice.GET_USER)
        return User.parse_response(self, result)

    def get_version(self) -> str:
        result = self.requests_get(AllSpice.ALLSPICE_HUB_VERSION)
        return result["version"]

    def get_users(self) -> List[User]:
        results = self.requests_get(AllSpice.GET_USERS_ADMIN)
        return [User.parse_response(self, result) for result in results]

    def get_user_by_email(self, email: str) -> User:
        users = self.get_users()
        for user in users:
            if user.email == email or email in user.emails:
                return user
        return None

    def get_user_by_name(self, username: str) -> User:
        users = self.get_users()
        for user in users:
            if user.username == username:
                return user
        return None

    def get_repository(self, owner: str, name: str) -> Repository:
        path = self.GET_REPOSITORY.format(owner=owner, name=name)
        result = self.requests_get(path)
        return Repository.parse_response(self, result)

    def create_user(
        self,
        user_name: str,
        email: str,
        password: str,
        full_name: Optional[str] = None,
        login_name: Optional[str] = None,
        change_pw=True,
        send_notify=True,
        source_id=0,
    ):
        """Create User.
        Throws:
            AlreadyExistsException, if the User exists already
            Exception, if something else went wrong.
        """
        if not login_name:
            login_name = user_name
        if not full_name:
            full_name = user_name
        request_data = {
            "source_id": source_id,
            "login_name": login_name,
            "full_name": full_name,
            "username": user_name,
            "email": email,
            "password": password,
            "send_notify": send_notify,
            "must_change_password": change_pw,
        }

        self.logger.debug("Gitea post payload: %s", request_data)
        result = self.requests_post(AllSpice.ADMIN_CREATE_USER, data=request_data)
        if "id" in result:
            self.logger.info(
                "Successfully created User %s <%s> (id %s)",
                result["login"],
                result["email"],
                result["id"],
            )
            self.logger.debug("Gitea response: %s", result)
        else:
            self.logger.error(result["message"])
            raise Exception("User not created... (gitea: %s)" % result["message"])
        user = User.parse_response(self, result)
        return user

    def create_repo(
        self,
        repoOwner: Union[User, Organization],
        repoName: str,
        description: str = "",
        private: bool = False,
        autoInit=True,
        gitignores: Optional[str] = None,
        license: Optional[str] = None,
        readme: str = "Default",
        issue_labels: Optional[str] = None,
        default_branch="master",
    ):
        """Create a Repository as the administrator

        Throws:
            AlreadyExistsException: If the Repository exists already.
            Exception: If something else went wrong.

        Note:
            Non-admin users can not use this method. Please use instead
            `allspice.User.create_repo` or `allspice.Organization.create_repo`.
        """
        # although this only says user in the api, this also works for
        # organizations
        assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization)
        result = self.requests_post(
            AllSpice.ADMIN_REPO_CREATE % repoOwner.username,
            data={
                "name": repoName,
                "description": description,
                "private": private,
                "auto_init": autoInit,
                "gitignores": gitignores,
                "license": license,
                "issue_labels": issue_labels,
                "readme": readme,
                "default_branch": default_branch,
            },
        )
        if "id" in result:
            self.logger.info("Successfully created Repository %s " % result["name"])
        else:
            self.logger.error(result["message"])
            raise Exception("Repository not created... (gitea: %s)" % result["message"])
        return Repository.parse_response(self, result)

    def create_org(
        self,
        owner: User,
        orgName: str,
        description: str,
        location="",
        website="",
        full_name="",
    ):
        assert isinstance(owner, User)
        result = self.requests_post(
            AllSpice.CREATE_ORG % owner.username,
            data={
                "username": orgName,
                "description": description,
                "location": location,
                "website": website,
                "full_name": full_name,
            },
        )
        if "id" in result:
            self.logger.info("Successfully created Organization %s" % result["username"])
        else:
            self.logger.error("Organization not created... (gitea: %s)" % result["message"])
            self.logger.error(result["message"])
            raise Exception("Organization not created... (gitea: %s)" % result["message"])
        return Organization.parse_response(self, result)

    def create_team(
        self,
        org: Organization,
        name: str,
        description: str = "",
        permission: str = "read",
        can_create_org_repo: bool = False,
        includes_all_repositories: bool = False,
        units=(
            "repo.code",
            "repo.issues",
            "repo.ext_issues",
            "repo.wiki",
            "repo.pulls",
            "repo.releases",
            "repo.ext_wiki",
        ),
        units_map={},
    ):
        """Creates a Team.

        Args:
            org (Organization): Organization the Team will be part of.
            name (str): The Name of the Team to be created.
            description (str): Optional, None, short description of the new Team.
            permission (str): Optional, 'read', What permissions the members
            units_map (dict): Optional, {}, a mapping of units to their
                permissions. If None or empty, the `permission` permission will
                be applied to all units. Note: When both `units` and `units_map`
                are given, `units_map` will be preferred.
        """

        result = self.requests_post(
            AllSpice.CREATE_TEAM % org.username,
            data={
                "name": name,
                "description": description,
                "permission": permission,
                "can_create_org_repo": can_create_org_repo,
                "includes_all_repositories": includes_all_repositories,
                "units": units,
                "units_map": units_map,
            },
        )

        if "id" in result:
            self.logger.info("Successfully created Team %s" % result["name"])
        else:
            self.logger.error("Team not created... (gitea: %s)" % result["message"])
            self.logger.error(result["message"])
            raise Exception("Team not created... (gitea: %s)" % result["message"])
        api_object = Team.parse_response(self, result)
        setattr(
            api_object, "_organization", org
        )  # fixes strange behaviour of gitea not returning a valid organization here.
        return api_object
