import json
import os
import sys
import time
from enum import Enum
from json import JSONEncoder
from typing import Any, Callable

import requests
from requests.structures import CaseInsensitiveDict

from entegywrapper.errors import EntegyInvalidAPIKeyError, EntegyNoDataError

sys.path.append(os.path.dirname(__file__))

API_ENDPOINTS = {
    "AU": "https://api.entegy.com.au",
    "US": "https://api-us.entegy.com.au",
    "EU": "https://api-eu.entegy.com.au",
}


class EnumJSONEncoder(JSONEncoder):
    """
    Custom JSON encoder that handles ENUMs.
    """

    def default(self, obj):
        if isinstance(obj, Enum):
            return obj.value
        return super().default(obj)


class EntegyAPI:
    from .attendance_tracking.attendance_tracking import add_check_in, get_attended, get_attendees
    from .content.categories import (
        available_categories,
        create_categories,
        create_child_categories,
        delete_categories,
        deselect_categories,
        select_categories,
        update_category,
    )
    from .content.content import (
        add_children_content,
        create_content,
        delete_content,
        get_content,
        get_schedule_content,
        update_content,
    )
    from .content.documents import add_documents, add_external_content_documents
    from .content.multi_link import (
        add_multi_links,
        get_multi_links,
        remove_all_multi_links,
        remove_multi_link,
    )
    from .notification.notification import send_bulk_notification, send_notification
    from .plugins.ext_auth import external_authentication
    from .points.point_management import award_points, get_point_leaderboard, get_points
    from .profiles.profile_custom import (
        all_profile_custom,
        create_profile_custom,
        delete_profile_custom,
        get_profile_custom,
        update_profile_custom,
    )
    from .profiles.profile_links import (
        clear_profile_links,
        deselect_profile_links,
        multi_select_profile_links,
        page_profile_links,
        select_profile_link,
        selected_profile_links,
    )
    from .profiles.profile_payments import add_profile_payment
    from .profiles.profile_types import (
        all_profile_types,
        create_profile_type,
        delete_profile_type,
        get_profile_type,
        update_profile_type,
    )
    from .profiles.profiles import (
        all_profiles,
        create_profile,
        delete_profile,
        get_profile,
        send_welcome_email,
        sync_profile_block,
        sync_profiles,
        update_profile,
    )

    def __init__(
        self,
        api_key: str | list[str],
        api_secret: str | list[str],
        project_id: str,
        region: str = "AU",
    ):
        """
        Creates an Entegy API wrapper to interact with the specified project.

        Parameters
        ----------
            `api_key` (`str | list[str]`): Entegy API key(s)

            `api_secret` (`str | list[str]`): Entegy API secret key(s)

            `project_id` (`str`): Entegy project ID

            `region` (`str`, optional): project region: one of "AU", "US", "EU"; defaults to "AU"
        """
        if isinstance(api_key, list):
            assert isinstance(api_secret, list)
            assert len(api_key) == len(api_secret)
            assert all(isinstance(key, str) for key in api_key)
            assert all(isinstance(secret, str) for secret in api_secret)
        else:
            assert isinstance(api_key, str)
            assert isinstance(api_secret, str)
        assert isinstance(project_id, str)
        assert isinstance(region, str)
        assert region in API_ENDPOINTS.keys()

        if isinstance(api_key, list) and isinstance(api_secret, list):
            self.api_key = list(map(lambda x: x.strip(), api_key))
            self.api_secret = list(map(lambda x: x.strip(), api_secret))
        elif isinstance(api_key, list) or isinstance(api_secret, list):
            raise ValueError("API key and secret must both be lists or both be strings")
        else:
            self.api_key = api_key.strip()
            self.api_secret = api_secret.strip()

        self.current_key_pair = 0
        self.project_id = project_id.strip()

        self.headers = CaseInsensitiveDict()
        self.headers["Content-Type"] = "application/json"
        self.get_key()

        self.api_endpoint = API_ENDPOINTS[region]

    def get_key(self) -> str:
        """
        Returns the API Key. If a list of keys was provided, the current key is
        returned.

        Returns
        -------
            `str`: API Key
        """
        if isinstance(self.api_key, list):
            self.headers["Authorization"] = f"ApiKey {self.api_secret[self.current_key_pair]}"
            return self.api_key[self.current_key_pair]

        self.headers["Authorization"] = f"ApiKey {self.api_secret}"
        return self.api_key

    def cycle_key(self):
        """
        Cycles to the next API keypair, wrapping to the first where necessary.
        """
        self.current_key_pair = (self.current_key_pair + 1) % len(self.api_key)

    def get_endpoint(self) -> str:
        """
        Returns the endpoint URL.

        Returns
        -------
            `str`: API endpoint URL
        """
        return self.api_endpoint

    def request(self, method: Callable, endpoint: str, data: dict) -> dict:
        """
        Sends the given data to the given endpoint of the Entegy API, using the given
        method. Internalised to allow for automatic key cycling and error handling.

        Parameters
        ----------
            `method` (`Callable`): method to use to send the request

            `endpoint` (`str`): API endpoint to which to post

            `data` (`dict`): data to post

        Raises
        ------
            `EntegyInvalidAPIKeyError`: if the API keys are invalid

        Returns
        -------
            `dict`: response data
        """
        keys_attempted = 0
        failed_requests = 0

        data |= {"apiKey": self.get_key(), "projectId": self.project_id}

        response = None
        while response is None:
            response = method(
                endpoint, headers=self.headers, data=json.dumps(data, cls=EnumJSONEncoder)
            )

            try:
                response = response.json()
            except:
                failed_requests += 1
                if failed_requests >= 5:
                    raise EntegyNoDataError()

                response = None
                continue

            match response["response"]:
                case 403:  # invalid API key
                    failed_requests += 1
                    if failed_requests >= 5:
                        raise EntegyInvalidAPIKeyError()

                    response = None
                case 489:  # rate-limit
                    if keys_attempted >= len(self.api_key):
                        duration = response["resetDuration"]
                        print(f"Rate limit reached, waiting {duration} seconds")
                        time.sleep(duration + 2)
                        print("Continuing...")
                        keys_attempted = 0
                        response = None
                    else:
                        self.cycle_key()
                        key = self.get_key()
                        data["apiKey"] = key
                        print(f"Rate limit reached, trying alternate key: {key}")
                        keys_attempted += 1
                        response = None

        return response

    def post(self, endpoint: str, data: dict) -> dict:
        """
        Posts the given data to the given endpoint of the Entegy API.

        Parameters
        ----------
            `endpoint` (`str`): API endpoint to which to post

            `data` (`dict`): data to post

        Returns
        -------
            `dict`: response data
        """
        return self.request(requests.post, endpoint, data)

    def delete(self, endpoint: str, data: dict) -> dict:
        """
        Deletes the given data from the given endpoint of the Entegy API.

        Parameters
        ----------
            `endpoint` (`str`): API endpoint from which to delete

            `data` (`dict`): data to delete

        Returns
        -------
            `dict`: response data
        """
        return self.request(requests.delete, endpoint, data)
