"""Permobil API."""

import asyncio
import datetime
import re

import aiohttp
from aiocache import Cache

from .const import (
    ENDPOINT_APPLICATIONAUTHENTICATIONS,
    ENDPOINT_APPLICATIONLINKS,
    ENDPOINT_LOOKUP,
    ENDPOINT_PRODUCTS,
    ENDPOINT_BATTERY_INFO,
    ENDPOINT_DAILY_USAGE,
    ENDPOINT_VA_USAGE_RECORDS,
    ENDPOINT_PRODUCTS_POSITIONS,
    PRODUCTS_ID,
    GET_REGIONS,
    EMAIL_REGEX,
    GET,
    POST,
    PUT,
    DELETE,
)


class MyPermobilException(Exception):
    """Permobil Exception. Generic Permobil Exception."""


class MyPermobilAPIException(MyPermobilException):
    """Permobil Exception. Exception raised when the API returns an error."""


class MyPermobilConnectionException(MyPermobilException):
    """Permobil Exception. Exception raised when the AIOHTTP."""


class MyPermobilClientException(MyPermobilException):
    """Permobil Exception. Exception raised when the Client is used incorrectly."""


class MyPermobilEulaException(MyPermobilException):
    """Permobil Exception. Exception raised when the client has not accepted the EULA."""


CACHE: Cache = Cache()
CACHE_TTL = 5 * 60  # 5 minutes
CACHE_ERROR_TTL = 10  # 10 seconds
CACHE_LOCKS = {}


async def async_get_cache(key):
    """Get cache."""
    # get the cached data
    res = await CACHE.get(key)
    if isinstance(res, Exception):
        # if the cached data is an error, raise instead of returning
        raise res
    return res


def cacheable(func):
    """Decorator to cache function calls for methods that
    fetch multiple data points from the API.
    """

    async def wrapper(*args, **kwargs):
        """wrapper."""
        key = func.__name__ + str(args) + str(kwargs)
        # check if the request is already cached
        cached_data = await async_get_cache(key)
        if cached_data:
            # return cached data
            return cached_data

        # check if the request is already in progress
        if key in CACHE_LOCKS:
            # request is already in progress, wait for it to finish
            await CACHE_LOCKS[key].wait()
            res = await async_get_cache(key)
            return res  # return cached data once it has finished by other task

        # start request it and lock other requests from starting
        CACHE_LOCKS[key] = asyncio.Event()

        try:
            response = await func(*args, **kwargs)  # make the request
            await CACHE.set(key, response, ttl=CACHE_TTL)  # cache the response
        except Exception as err:  # pylint: disable=broad-except
            # if there is an error, cache the error and raise it
            await CACHE.set(key, err, ttl=CACHE_ERROR_TTL)
            raise err
        finally:
            # regardless of the outcome, unlock other threads
            CACHE_LOCKS[key].set()
            del CACHE_LOCKS[key]
        return response

    return wrapper


def validate_email(email: str) -> str:
    """Validates an email."""
    if not email:
        raise MyPermobilClientException("Missing email")
    if not re.match(EMAIL_REGEX, email):
        raise MyPermobilClientException("Invalid email")
    return email


def validate_code(code: str) -> str:
    """Validates an code."""
    if not code:
        raise MyPermobilClientException("Missing code")
    if " " in code or "\n" in code:
        raise MyPermobilClientException("Code cannot contain spaces or newlines")
    if not code.isdigit():
        raise MyPermobilClientException("Code must be a number")
    if len(code) != 6:
        raise MyPermobilClientException("Code must be 6 digits long")
    return code


def validate_token(token: str) -> str:
    """Validates an token."""
    if not token:
        raise MyPermobilClientException("Missing token")
    if len(token) != 256:
        # the token must be 256 characters long
        raise MyPermobilClientException("Invalid token")
    return token


def validate_expiration_date(expiration_date: str) -> str:
    """Validates an expiration date, both format and value."""
    if not expiration_date:
        raise MyPermobilClientException("Missing expiration date")
    date = None
    try:
        date = datetime.datetime.strptime(expiration_date, "%Y-%m-%d")
    except ValueError as err:
        raise MyPermobilClientException("Invalid expiration date") from err
    # check if the expiration date is in the future
    if date < datetime.datetime.now():
        raise MyPermobilClientException("Expired token")
    return expiration_date


def validate_region(region: str) -> str:
    """Validates an region."""
    if not region:
        raise MyPermobilClientException("Missing region")
    if not region.startswith("https://") and not region.startswith("http://"):
        raise MyPermobilClientException("Region missing protocol")
    return region


def validate_product_id(product_id: str) -> str:
    """Validates a product_id."""
    if not product_id:
        raise MyPermobilClientException("Missing product id")
    if len(product_id) != 24:
        raise MyPermobilClientException("Invalid product id")
    return product_id


async def create_session():
    """Create a client session."""
    return aiohttp.ClientSession()


class MyPermobil:
    """Permobil API."""

    request_timeout = 10

    def __init__(
        self,
        application: str,
        session: aiohttp.ClientSession,
        email: str = None,
        region: str = None,
        code: str = None,
        token: str = None,
        expiration_date: str = None,
        product_id: str = None,
    ) -> None:
        """Initialize."""
        self.application = application
        self.session = session
        self.email = email
        self.region = region
        self.code = code
        self.token = token
        self.expiration_date = expiration_date
        self.product_id = product_id

        self.authenticated = False

    # Magic methods
    def __str__(self) -> str:
        """str."""
        app, email, region = self.application, self.email, self.region
        code, token, exp = self.code, self.token, self.expiration_date
        return f"Permobil({app}, {email}, {region}, {code}, {token}, {exp})"

    # Selectors
    @property
    def headers(self):
        """headers."""
        if not self.authenticated:
            raise MyPermobilClientException("Not authenticated")
        return {"Authorization": f"Bearer {self.token}"}

    def set_email(self, email: str):
        """Set email."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change email after authentication")
        self.email = validate_email(email)

    def set_region(self, region: str):
        """Set region."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change region after authentication")
        self.region = validate_region(region)

    def set_code(self, code: int):
        """Set code."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change code after authentication")
        self.code = validate_code(code)

    def set_token(self, token: str):
        """Set token."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change token after authentication")
        self.token = validate_token(token)

    def set_expiration_date(self, expiration_date: str):
        """Set expiration date."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change date after authentication")
        self.expiration_date = validate_expiration_date(expiration_date)

    def set_product_id(self, product_id: str):
        """Set product id."""
        self.product_id = validate_product_id(product_id)

    def set_application(self, application: str):
        """Set application."""
        if self.authenticated:
            raise MyPermobilClientException("Cannot change app after authentication")
        self.application = application

    async def close_session(self):
        """Close session."""
        if self.session is None:
            raise MyPermobilClientException("Session does not exist")
        await self.session.close()
        self.session = None

    def self_authenticate(self):
        """authenticate. Manually set token and expiration date."""
        if self.authenticated:
            raise MyPermobilClientException("Already authenticated")
        if not self.application:
            raise MyPermobilClientException("Missing application name")
        validate_region(self.region)
        validate_email(self.email)
        validate_token(self.token)
        validate_expiration_date(self.expiration_date)
        self.authenticated = True

    def self_reauthenticate(self):
        """Use when token is expired.

        Reset token, expiration, and code. Sets authenticated to False.
        """
        if self.authenticated:
            raise MyPermobilClientException("Already authenticated")
        if not self.application:
            raise MyPermobilClientException("Missing application name")
        self.token = None
        self.expiration_date = None
        self.code = None
        self.authenticated = False

    # API Methods
    async def make_request(self, request_type: str, *args, **kwargs):
        """make a post, get, put or delete request"""
        if not kwargs.get("timeout"):
            kwargs["timeout"] = self.request_timeout
        if not kwargs.get("headers") and self.authenticated:
            kwargs["headers"] = self.headers

        if request_type not in (GET, POST, PUT, DELETE):
            raise MyPermobilClientException("Invalid request type")

        try:
            if request_type == GET:
                return await self.session.get(*args, **kwargs)
            if request_type == POST:
                return await self.session.post(*args, **kwargs)
            if request_type == PUT:
                return await self.session.put(*args, **kwargs)
            if request_type == DELETE:
                return await self.session.delete(*args, **kwargs)
        except aiohttp.ClientConnectorError as err:
            raise MyPermobilConnectionException("Connection error") from err
        except asyncio.TimeoutError as err:
            raise MyPermobilConnectionException("Connection timeout") from err
        except aiohttp.ClientError as err:
            raise MyPermobilConnectionException("Client error") from err
        except Exception as err:
            raise MyPermobilAPIException("Unknown error") from err

    @cacheable
    async def request_regions(
        self, include_icons: bool = False, include_internal: bool = False
    ):
        """Get regions."""
        if self.email and self.email.endswith("@permobil.com"):
            include_internal = True

        response = await self.make_request(GET, GET_REGIONS, headers={})
        if response.status == 200:
            response_json = await response.json()
            regions = {}
            for region in response_json:
                if not include_internal:
                    if (
                        region.get("backendPort") != 443
                        or region.get("serverType") != "Production"
                    ):
                        continue
                region_id = region.get("_id")
                regions[region_id] = {}
                regions[region_id]["name"] = region.get("name")
                regions[region_id]["port"] = region.get("backendPort")
                protocol = "https" if region.get("backendPort") == 443 else "http"
                regions[region_id]["url"] = f"{protocol}://{region.get('host')}"
                if include_icons:
                    regions[region_id]["icon"] = region.get("flag")
            return regions

        if response.status in (404, 500):
            text = await response.text()
            raise MyPermobilAPIException(text)

    async def request_region_names(self, include_internal: bool = False):
        """Get region names."""
        regions = await self.request_regions(
            include_icons=False, include_internal=include_internal
        )
        return {
            regions[region_id].get("name"): regions[region_id].get("url")
            for region_id in regions
        }

    async def request_application_code(
        self, email: str = None, region: str = None, application: str = None
    ):
        """Post application link."""
        if email is None:
            email = self.email
        if region is None:
            region = self.region
        if application is None:
            application = self.application

        if self.authenticated:
            raise MyPermobilClientException("Already authenticated")
        if not application:
            raise MyPermobilClientException("Missing application name")
        email = validate_email(email)
        region = validate_region(region)

        url = region + ENDPOINT_APPLICATIONLINKS
        json = {"username": email, "application": application}
        response = await self.make_request(POST, url, json=json)
        if response.status != 204:
            text = await response.text()
            raise MyPermobilAPIException(text)

    async def request_application_token(
        self,
        email: str = None,
        code: int = None,
        region: str = None,
        application: str = None,
        expiration_date: str = None,
    ) -> tuple:
        """Post the application token."""
        if email is None:
            email = self.email
        if code is None:
            code = self.code
        if region is None:
            region = self.region
        if application is None:
            application = self.application
        if expiration_date is None:
            # set expiration date to 1 year from now
            time_delta = datetime.timedelta(days=365)
            date = datetime.datetime.now() + time_delta
            expiration_date = date.strftime("%Y-%m-%d")

        if self.authenticated:
            raise MyPermobilClientException("Already authenticated")
        email = validate_email(email)
        region = validate_region(region)
        code = validate_code(code)
        expiration_date = validate_expiration_date(expiration_date)

        url = region + ENDPOINT_APPLICATIONAUTHENTICATIONS
        json = {
            "username": email,
            "code": code,
            "application": application,
            "expirationDate": expiration_date,
        }
        response = await self.make_request(POST, url, json=json)

        if response.status == 200:
            json = await response.json()
            token = json.get("token")

        elif response.status == 401:
            raise MyPermobilAPIException("Email not registered for region")
        elif response.status == 403:
            raise MyPermobilAPIException("Incorrect code")
        elif response.status == 430:
            raise MyPermobilEulaException("Please accept the EULA")
        elif response.status in (400, 500):
            resp = await response.json()
            raise MyPermobilAPIException(resp.get("error", resp))
        else:
            text = await response.text()
            raise MyPermobilAPIException(text)

        return token, expiration_date

    async def deauthenticate(
        self,
        email: str = None,
        region: str = None,
        application: str = None,
        headers: dict = None,
    ):
        """deauthenticate."""
        if email is None:
            email = self.email
        if region is None:
            region = self.region
        if application is None:
            application = self.application
        if headers is None:
            headers = self.headers

        if not application:
            raise MyPermobilClientException("Missing application name")

        email = validate_email(email)
        region = validate_region(region)

        url = region + ENDPOINT_APPLICATIONLINKS
        json = {"application": application}
        response = await self.make_request(DELETE, url, json=json, headers=headers)
        if response.status != 204:
            text = await response.text()
            raise MyPermobilAPIException(text)

    async def request_product_id(self, headers: dict = None) -> str:
        """Get product id from the API."""
        # this function is equivalent to request_item(PRODUCTS_ID, ENDPOINT_PRODUCTS)
        # but it has better error handling
        if headers is None:
            headers = self.headers

        response = await self.request_endpoint(ENDPOINT_PRODUCTS, headers)
        if not isinstance(response, list):
            raise MyPermobilAPIException("Invalid response")
        if len(response) != 1:
            raise MyPermobilAPIException("Wrong number of products found")

        return response[PRODUCTS_ID[0]][PRODUCTS_ID[1]]

    async def request_item(
        self, items: str | list[str], endpoint: str = None, **kwargs
    ) -> str | int | float | bool | dict | list:
        """Takes a single item or list of items, finds the endpoint and makes the request."""
        if not items:
            raise MyPermobilClientException("No item(s) provided")

        if isinstance(items, str):
            items = [items]

        if endpoint is None:
            key = str(items)
            if key in ENDPOINT_LOOKUP:
                endpoint = ENDPOINT_LOOKUP.get(key)
            else:
                raise MyPermobilClientException(f"No endpoint for: {key}")

        # dive into the response for each item in the list
        response = await self.request_endpoint(endpoint, kwargs)
        for item in items:
            if isinstance(response, dict) and item not in response:
                raise MyPermobilClientException(f"{item} not in response")
            if isinstance(response, list) and item >= len(response):
                raise MyPermobilClientException(
                    f"Too few items in response {item} >= {len(response)}"
                )
            response = response[item]
        return response

    @cacheable
    async def request_endpoint(
        self, endpoint: str, headers: dict = None, product_id: str = None
    ) -> dict:
        """Makes a request to an endpoint."""
        if headers is None:
            headers = self.headers
        if product_id is None:
            product_id = self.product_id

        endpoint = self.region + endpoint.format(product_id=product_id)
        resp = await self.make_request(GET, endpoint, headers=headers)
        status = resp.status
        try:
            json = await resp.json()
            if status >= 200 and status < 300:
                return json
            text = await resp.text()
            message = json.get("error", text)
        except aiohttp.client_exceptions.ContentTypeError:
            message = await resp.text()
        raise MyPermobilAPIException(f"{status}: {message}")
    
    async def get_battery_info(self) -> dict:
        """ request battery info """
        return await self.request_endpoint(ENDPOINT_BATTERY_INFO)

    async def get_daily_usage(self) -> dict:
        """ request daily usage info """
        return await self.request_endpoint(ENDPOINT_DAILY_USAGE)

    async def get_usage_records(self) -> dict:
        """ request records info """
        return await self.request_endpoint(ENDPOINT_VA_USAGE_RECORDS)

    async def get_gps_position(self) -> dict:
        """ request gps info """
        return await self.request_endpoint(ENDPOINT_PRODUCTS_POSITIONS)
