from __future__ import annotations  # Needed for returning the same class type where using factory static builders

import base64
import hashlib
import json
import logging
import os
import platform
import random
import string
import time
import uuid
from datetime import datetime
from typing import Optional, List

from aiohttp import ClientSession

from meross_iot.model.credentials import MerossCloudCreds
# Appears to be used as a part of the signature algorithm as constant "salt" (kinda useless)
from meross_iot.model.http.device import HttpDeviceInfo
from meross_iot.model.http.error_codes import ErrorCodes
from meross_iot.model.http.exception import TooManyTokensException, TokenExpiredException, AuthenticatedPostException, \
    HttpApiError, BadLoginException, BadDomainException, MissingMFA, WrongMFA
from meross_iot.model.http.subdevice import HttpSubdeviceInfo
from meross_iot.utilities.misc import current_version
from meross_iot.utilities.stats import HttpStatsCounter

_LOGGER = logging.getLogger(__name__)

_SECRET = "23x17ahWarFH6w29"
_LOGIN_URL = "%s/v1/Auth/signIn"
_LOG_URL = "%s/v1/log/user"
_DEV_LIST = "%s/v1/Device/devList"
_HUB_DUBDEV_LIST = "%s/v1/Hub/getSubDevices"
_LOGOUT_URL = "%s/v1/Profile/logout"
_MODULE_VERSION = current_version()
_DEFAULT_UA_HEADER = f"MerossIOT/{_MODULE_VERSION}"
_DEFAULT_APP_TYPE = "MerossIOT"
_DEFAULT_LOG_IDENTIFIER = '%030x' % random.randrange(16 ** 30) + str(uuid.uuid4())


class MerossHttpClient(object):
    """
    Utility class for dealing with Meross HTTP API.
    This class simplifies the usage of the Meross HTTP API providing login, logout and device listing API.
    """

    def __init__(self,
                 cloud_credentials: MerossCloudCreds,
                 http_proxy: str = None,
                 ua_header: str = _DEFAULT_UA_HEADER,
                 app_type: str = _DEFAULT_APP_TYPE,
                 app_version: str = _MODULE_VERSION,
                 log_identifier: str = _DEFAULT_LOG_IDENTIFIER):
        """
        Http library client constructor
        :param cloud_credentials: Credentials object to use for authentication
        :param http_proxy: HTTP proxy to use. When none, no proxy will be used. Defaults to None.
        :param ua_header: User agent headers to send alongside the various requests. The default value is auto-generated by the library.
        :param app_type: String used to discriminate the app type. The default value is auto-generated by the library.
        :param app_version: String used to discriminate the app version. The default value is auto-generated by the library.
        :param log_identifier: String used to log the app client usage. The default value is auto-generated by the library.
        """
        self._cloud_creds = cloud_credentials
        self._enable_proxy = False
        self._proxy_type = None
        self._proxy_addr = None
        self._http_proxy = http_proxy
        self._ua_header = ua_header
        self._stats_counter = HttpStatsCounter()
        self._log_identifier = log_identifier
        self._app_type = app_type
        self._app_version = app_version
        self._api_url = cloud_credentials.domain


    @property
    def stats(self) -> HttpStatsCounter:
        return self._stats_counter

    @property
    def cloud_credentials(self) -> MerossCloudCreds:
        """
        Returns the meross credentials returned by the the HTTP Api
        :return:
        """
        return self._cloud_creds

    @classmethod
    async def async_from_user_password(cls,
                                       api_base_url: str,
                                       email: str,
                                       password: str,
                                       http_proxy: str = None,
                                       ua_header: str = _DEFAULT_UA_HEADER,
                                       app_type: str = _DEFAULT_APP_TYPE,
                                       app_version: str = _MODULE_VERSION,
                                       log_identifier: str = _DEFAULT_LOG_IDENTIFIER,
                                       auto_retry_on_bad_domain: bool=True,
                                       mfa_code: string = None,
                                       *args, **kwargs) -> MerossHttpClient:
        """
        Builds a MerossHttpClient using username/password combination.
        In any case, the login will generate a token, which might expire at any time.

        :param api_base_url: Https base endpoint to use for API calls. It should be one of "https://iotx-eu.meross.com", "https://iotx-ap.meross.com" or "https://iotx-us.meross.com", based on your public IP region.,
        :param email: Meross account email
        :param password: Meross account password
        :param http_proxy: Optional http proxy to use when issuing the requests
        :param app_type: App Type header parameter to use
        :param app_version:  App Version header parameter to use
        :param log_identifier: Log identifier to use
        :param auto_retry_on_bad_domain: when set, it enables auto-retry when BadDomain exception occurs.
        :param mfa_code: multi-factor authentication code (optional)

        :return: an instance of `MerossHttpClient`
        """
        _LOGGER.debug(f"Logging in with email: {email}, password: XXXXX")
        creds = await cls.async_login(email=email,
                                      password=password,
                                      api_base_url=api_base_url,
                                      http_proxy=http_proxy,
                                      ua_header=ua_header,
                                      app_type=app_type,
                                      app_version=app_version,
                                      log_identifier=log_identifier,
                                      auto_retry_on_bad_domain=auto_retry_on_bad_domain,
                                      mfa_code=mfa_code)
        # Call log
        await cls._async_log(creds=creds,
                             api_base_url=api_base_url,
                             http_proxy=http_proxy,
                             ua_header=ua_header,
                             app_type=app_type,
                             app_version=app_version,
                             log_identifier=log_identifier)

        _LOGGER.debug(f"Login successful!")
        return MerossHttpClient(cloud_credentials=creds,
                                http_proxy=http_proxy,
                                ua_header=ua_header,
                                app_type=app_type,
                                app_version=app_version,
                                log_identifier=log_identifier)

    @classmethod
    async def async_from_cloud_creds(cls,
                                     creds: MerossCloudCreds,
                                     http_proxy: str = None,
                                     ua_header: str = _DEFAULT_UA_HEADER,
                                     app_type: str = _DEFAULT_APP_TYPE,
                                     app_version: str = _MODULE_VERSION,
                                     log_identifier: str = _DEFAULT_LOG_IDENTIFIER,
                                     *args,
                                     **kwargs) -> MerossHttpClient:
        """
        Build a MerossIot API client using the provided cloud-credentials object.
        :param creds: CloudCredentials object to build the client from
        :param http_proxy: HTTP proxy to use. When none, no proxy will be used. Defaults to None.
        :param ua_header: User agent headers to send alongside the various requests. The default value is auto-generated by the library.
        :param app_type: String used to discriminate the app type. The default value is auto-generated by the library.
        :param app_version: String used to discriminate the app version. The default value is auto-generated by the library.
        :param log_identifier: String used to log the app client usage. The default value is auto-generated by the library.
        :return:
        """

        # Use _log method to verify the credentials
        verify_creds = await cls._async_log(creds=creds,
                                            api_base_url=creds.domain,
                                            http_proxy=http_proxy,
                                            ua_header=ua_header,
                                            app_type=app_type,
                                            app_version=app_version,
                                            log_identifier=log_identifier)

        return MerossHttpClient(cloud_credentials=creds,
                                http_proxy=http_proxy,
                                ua_header=ua_header,
                                app_type=app_type,
                                app_version=app_version,
                                log_identifier=log_identifier)

    @classmethod
    async def async_login(cls,
                          api_base_url: str,
                          email: str,
                          password: str,
                          creds_env_var_name: str = '__MEROSS_CREDS',
                          http_proxy: str = None,
                          ua_header: str = _DEFAULT_UA_HEADER,
                          app_type: str = _DEFAULT_APP_TYPE,
                          app_version: str = _MODULE_VERSION,
                          log_identifier: str = _DEFAULT_LOG_IDENTIFIER,
                          country_code: str = "us",
                          agree_to_terms: int = 0,
                          mfa_code: str = None,
                          stats_counter: HttpStatsCounter = None,
                          auto_retry_on_bad_domain: bool = True,
                          *args, **kwargs) -> MerossCloudCreds:
        """
        Performs the login against the Meross HTTP endpoint.
        This api returns a MerossCloudCreds object, which contains the API endpoint, the mqtt endpoint and the
        authorization token to use the various API/MQTT services.
        Be cautious when invoking this API: asking for too many tokens as the Meross HTTP API might refuse
        to issue more tokens. Instead, you should keep using the same issued token when possible, possibly
        storing it across sessions. When you are done using a specific token, be sure to invoke logout
        to invalidate it.

        :param api_base_url: Meross API base url.
        :param email: Meross account email
        :param password: Meross account password
        :param creds_env_var_name: If set, indicate which env variables stores such credentials
        :param http_proxy: Optional http proxy to use when to performing the request
        :param ua_header: User Agent header to use when issuing the HTTP request
        :param stats_counter: Stats counter object
        :param app_type: App Type header parameter to use
        :param app_version:  App Version header parameter to use
        :param country_code: (experimental) The country code you are connecting from
        :param agree_to_terms: (optional) if 1 means we agree, if 0 or omitted, means we do not agree to terms
        :param mfa_code: (optional) MFA code
        :param auto_retry_on_bad_domain: when set, tells the library to retry the login when BadDomain occurs, osing the right domain value returned by the initial operation.

        :return: a `MerossCloudCreds` object
        """
        # Calculate password's md5
        m = hashlib.md5()
        m.update(password.encode("utf8"))
        hashed_password = m.hexdigest()
        data = { "email": email,
                 "password": hashed_password,
                 "accountCountryCode": country_code,
                 "encryption": 1,
                 "agree": 1 if agree_to_terms else 0,
                 "mobileInfo": {
                     "deviceModel": platform.machine(),
                     "mobileOsVersion": platform.version(),
                     "mobileOs": platform.system(),
                     "uuid": log_identifier,
                     "carrier":""
                 }
             }

        # MFACode is optional
        if mfa_code is not None:
            data["mfaCode"] = mfa_code

        url = _LOGIN_URL % api_base_url
        try:
            response_data = await MerossHttpClient._async_authenticated_post(url=url,
                                                                             params_data=data,
                                                                             mask_params_in_log=True,
                                                                             http_proxy=http_proxy,
                                                                             ua_header=ua_header,
                                                                             app_type=app_type,
                                                                             app_version=app_version,
                                                                             stats_counter=stats_counter)
        except BadDomainException as e:
            _LOGGER.error(f"Login API redirected to different region: {e.api_domain}. Login will be re-attempted")
            if auto_retry_on_bad_domain:
                return await MerossHttpClient.async_login(email=email, password=password, creds_env_var_name=creds_env_var_name, api_base_url=e.api_domain,http_proxy=http_proxy,ua_header=ua_header,app_type=app_type,app_version=app_version,log_identifier=log_identifier,country_code=country_code,agree_to_terms=agree_to_terms,mfa_code=mfa_code,stats_counter=stats_counter, auto_retry_on_bad_domain=False, *args, **kwargs)
            else:
                _LOGGER.exception(f"Login failed against {api_base_url}")
                raise e
        except HttpApiError as e:
            if e.error_code == ErrorCodes.MFA_CODE_REQUIRED:
                raise MissingMFA() from e
            elif e.error_code == ErrorCodes.WRONG_MFA_CODE:
                raise WrongMFA() from e
            raise e

        _LOGGER.info(f"Login successful against {api_base_url}")

        creds = MerossCloudCreds(
            token=response_data["token"],
            key=response_data["key"],
            user_id=response_data["userid"],
            user_email=response_data["email"],
            domain=response_data["domain"],
            mqtt_domain=response_data["mqttDomain"],
            issued_on=datetime.utcnow()
        )
        if creds_env_var_name is not None:
            os.environ[creds_env_var_name] = base64.b64encode(creds.to_json().encode("utf8")).decode("utf8")
        return creds

    @classmethod
    async def _async_authenticated_post(cls,
                                        url: str,
                                        params_data: dict,
                                        cloud_creds: Optional[MerossCloudCreds] = None,
                                        mask_params_in_log: bool = False,
                                        http_proxy: str = None,
                                        app_type: str = _DEFAULT_APP_TYPE,
                                        app_version: str = _MODULE_VERSION,
                                        ua_header: str = _DEFAULT_UA_HEADER,
                                        stats_counter: HttpStatsCounter = None
                                        ) -> dict:
        nonce = _generate_nonce(16)
        timestamp_millis = int(round(time.time() * 1000))
        encoded_params = _encode_params(params_data)

        # Generate the md5-hash (called signature)
        m = hashlib.md5()
        datatosign = '%s%s%s%s' % (_SECRET, timestamp_millis, nonce, encoded_params)
        m.update(datatosign.encode("utf8"))
        md5hash = m.hexdigest()

        headers = {
            "AppVersion": app_version,
            "Authorization": "Basic" if cloud_creds is None else "Basic %s" % cloud_creds.token,
            "vender": "meross",
            "AppType": app_type,
            "AppLanguage": "EN",
            "User-Agent": ua_header,
            "Content-Type": "application/json"
        }

        payload = {
            'params': encoded_params,
            'sign': md5hash,
            'timestamp': timestamp_millis,
            'nonce': nonce
        }

        # Perform the request.
        headers_with_masked_authrorization = headers.copy()
        if 'Authorization' in headers_with_masked_authrorization:
            headers_with_masked_authrorization['Authorization'] = 'XXXX-MASKED-XXXX'

        if not url.lower().startswith("http://") and not url.lower().startswith("https://"):
            _LOGGER.warning("Missing HTTP/HTTPS schema from Base API url. Assuming it's HTTPS://...")
            url = f"https://{url}"

        log_payload = payload
        if mask_params_in_log:
            log_payload = payload.copy()
            log_payload['params'] = 'XXXX-MASKED-XXXX'

        _LOGGER.debug(f"Performing HTTP request against {url}, headers: {headers}, post data: {payload}")
        async with ClientSession() as session:
            async with session.post(url, json=payload, headers=headers, proxy=http_proxy) as response:
                _LOGGER.debug(f"Response Status Code: {response.status}")
                # Check if that is ok.
                if response.status != 200:
                    if stats_counter is not None:
                        stats_counter.notify_http_request(request_url=url,
                                                          method="post",
                                                          http_response_code=response.status,
                                                          api_response_code=None)
                    raise AuthenticatedPostException("Failed request to API. Response code: %s" % str(response.status))

                # Save returned value
                jsondata = await response.json()
                code = jsondata.get('apiStatus')

                error = None
                try:
                    error = ErrorCodes(code)
                except ValueError as e:
                    raise AuthenticatedPostException(f"Unknown/Unhandled response code received from API. "
                                                     f"Response was: {jsondata}")
                finally:
                    # Keep track of the stats
                    if stats_counter is not None:
                        stats_counter.notify_http_request(request_url=url,
                                                          method="post",
                                                          http_response_code=response.status,
                                                          api_response_code=ErrorCodes.CODE_GENERIC_ERROR if error is None else error)
                    if error is None:
                        _LOGGER.error(f"Could not parse error code {code}.")
                    elif error == ErrorCodes.CODE_NO_ERROR:
                        return jsondata.get("data")
                    elif error in (ErrorCodes.CODE_TOKEN_EXPIRED, ErrorCodes.CODE_TOKEN_ERROR):
                        raise TokenExpiredException("The provided token has expired")
                    elif error == ErrorCodes.CODE_TOO_MANY_TOKENS:
                        raise TooManyTokensException("You have issued too many tokens without logging out and your "
                                                     "account might have been temporarly disabled.")
                    elif error in [ErrorCodes.CODE_WRONG_CREDENTIALS, ErrorCodes.CODE_UNEXISTING_ACCOUNT]:
                        raise BadLoginException("Invalid username/Password combination")
                    elif error == ErrorCodes.CODE_REDIRECT_REGION:
                        api_domain = jsondata.get("data").get("domain")
                        mqtt_domain = jsondata.get("data").get("mqttDomain")
                        raise BadDomainException(f"Invalid URL/API Endpoint used. Use: {api_domain} instead.", api_domain, mqtt_domain)
                    else:
                        _LOGGER.error(f"Received non-ok API status code: {error.name}. "
                                      f"Failed request to API. Response was: {jsondata}")
                        raise HttpApiError(error)

    async def async_logout(self,
                           *args,
                           **kwargs):
        """
        Invalidates the credentials stored in this object.

        :return: API response data
        """
        _LOGGER.debug(f"Logging out. Invalidating cached credentials {self._cloud_creds}")
        url = _LOGOUT_URL % self._api_url
        result = await MerossHttpClient._async_authenticated_post(url=url,
                                                                  params_data={},
                                                                  cloud_creds=self._cloud_creds,
                                                                  http_proxy=self._http_proxy,
                                                                  ua_header=self._ua_header,
                                                                  app_type=self._app_type,
                                                                  app_version=self._app_version,
                                                                  stats_counter=self._stats_counter)
        self._cloud_creds = None
        _LOGGER.info("Logout succeeded.")
        return result

    async def async_invalidate_credentials(self, creds: MerossCloudCreds):
        """
        Class method used to invalidate credentials without logging in with a full MerossHttpClient.

        :param creds: `MerossCloudCredentials` as returned by `async_login()` or `async_from_user_password()`
        :return: API response data
        """
        url = _LOGOUT_URL % self._api_url
        _LOGGER.debug(f"Logging out. Invalidating cached credentials {creds}")
        result = await MerossHttpClient._async_authenticated_post(url=url,
                                                                  params_data={},
                                                                  cloud_creds=creds,
                                                                  http_proxy=self._http_proxy,
                                                                  ua_header=self._ua_header,
                                                                  app_type=self._app_type,
                                                                  app_version=self._app_version,
                                                                  stats_counter=self._stats_counter)
        return result

    @classmethod
    async def _async_log(cls,
                         creds: MerossCloudCreds,
                         http_proxy: str = None,
                         ua_header: str = _DEFAULT_UA_HEADER,
                         app_type: str = _DEFAULT_APP_TYPE,
                         app_version: str = _MODULE_VERSION,
                         log_identifier: str = _DEFAULT_LOG_IDENTIFIER,
                         stats_counter: HttpStatsCounter = None,
                         *args,
                         **kwargs) -> dict:
        """
        Executes the LOG HTTP api. So far, it's still unknown whether this is needed and what it does.
        Most probably it logs the device specification to the remote endpoint for stats.

        :return:
        """
        data = {
            "system":platform.system(),
            "vendor":"meross",
            "uuid": log_identifier,
            "extra":"",
            "model":platform.machine(),
            "version":platform.version()
        }

        url = _LOG_URL % creds.domain
        return await cls._async_authenticated_post(url=url,
                                                   params_data=data,
                                                   cloud_creds=creds,
                                                   http_proxy=http_proxy,
                                                   ua_header=ua_header,
                                                   app_type=app_type,
                                                   app_version=app_version,
                                                   stats_counter=stats_counter
                                                   )

    async def async_list_devices(self,
                                 *args,
                                 **kwargs) -> List[HttpDeviceInfo]:
        """
        Asks to the HTTP api to list the Meross device belonging to the given user account.

        :return: a list of `HttpDeviceInfo`
        """
        url = _DEV_LIST % self._api_url
        result = await MerossHttpClient._async_authenticated_post(url=url,
                                                                  params_data={},
                                                                  cloud_creds=self._cloud_creds,
                                                                  http_proxy=self._http_proxy,
                                                                  ua_header=self._ua_header,
                                                                  app_type=self._app_type,
                                                                  app_version=self._app_version,
                                                                  stats_counter=self._stats_counter)
        return [HttpDeviceInfo.from_dict(x) for x in result]

    async def async_list_hub_subdevices(self,
                                        hub_id: str,
                                        *args,
                                        **kwargs) -> List[HttpSubdeviceInfo]:
        """
        Returns the sub-devices associated to the given hub.

        :param hub_id: Meross native UUID of the HUB

        :return: a list of `HttpSubdeviceInfo`
        """
        url = _HUB_DUBDEV_LIST % self._api_url
        result = await MerossHttpClient._async_authenticated_post(url=url,
                                                                  params_data={"uuid": hub_id},
                                                                  cloud_creds=self._cloud_creds,
                                                                  http_proxy=self._http_proxy,
                                                                  ua_header=self._ua_header,
                                                                  app_type=self._app_type,
                                                                  app_version=self._app_version,
                                                                  stats_counter=self._stats_counter)
        return [HttpSubdeviceInfo.from_dict(x) for x in result]

    def set_http_proxy(self, proxy_url: str):
        # TODO: validate proxy schema
        self._http_proxy = proxy_url


def _encode_params(parameters: dict):
    jsonstring = json.dumps(parameters)
    return str(base64.b64encode(jsonstring.encode("utf8")), "utf8")


def _generate_nonce(length: int):
    return ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(length))


def unauthenticated_command_executor(func, *args, **kwargs):
    def cmd(*iargs, **ikwargs):
        import asyncio

        # Fix event loop for Windows env
        if os.name == 'nt':
            import asyncio
            asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

        params = {k: v for (k, v) in iargs[0].__dict__.items() if k not in ("func",)}

        loop = asyncio.get_event_loop()
        data = loop.run_until_complete(func(**params))
        print(data)
        loop.stop()

    return cmd


def authenticated_command_executor(method, *args, **kwargs):
    def cmd(*iargs, **ikwargs):
        import asyncio
        import os

        async def execute(method, *args, **kwargs):
            b64creds = os.getenv("__MEROSS_CREDS", None)
            if b64creds is None:
                raise ValueError("This method requires __MEROSS_CREDS env variable set. "
                                 "Please invoke auth login first.")

            jsoncreds = base64.b64decode(b64creds)
            creds = MerossCloudCreds.from_json(jsoncreds)
            client = await MerossHttpClient.async_from_cloud_creds(creds)
            m = getattr(client, method)
            return await m(*args, **kwargs)

        # Fix event loop for Windows env
        if os.name == 'nt':
            import asyncio
            asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

        params = {k: v for (k, v) in iargs[0].__dict__.items() if k not in ("func", "credentials")}

        loop = asyncio.get_event_loop()
        data = loop.run_until_complete(execute(method, **params))
        print(data)
        loop.stop()

    return cmd


def main():
    import sys
    from argparse import ArgumentParser
    # Root parser
    parser = ArgumentParser(
        prog="meross_api_cli",
        description="Meross HTTP API utility",
        epilog="Created by Alberto Geniola")
    subparsers = parser.add_subparsers()

    # auth parser
    parser_auth = subparsers.add_parser('auth')
    auth_subparsers = parser_auth.add_subparsers()

    # auth login
    auth_login = auth_subparsers.add_parser('login',
                                            description="Logs in into meross HTTP api and prints out the credentials")
    auth_login.add_argument('--api-base-url', type=str, required=True, help='Api base URL to login against. Should be one among: https://iotx-eu.meross.com, https://iotx-us.meross.com, https://iotx-ap.meross.com')
    auth_login.add_argument('--email', type=str, required=True, help='Account username/email')
    auth_login.add_argument('--password', type=str, required=True, help='Account password')
    auth_login.add_argument('--creds-env-var-name', type=str, required=False,
                            help='When set, tells the library to store the credentials (upon successful login) within the indicated env var name')
    auth_login.set_defaults(func=unauthenticated_command_executor(MerossHttpClient.async_login))

    # auth logout
    auth_login = auth_subparsers.add_parser('logout', description="Logs out by invalidating the current token")
    auth_login.set_defaults(func=authenticated_command_executor("async_logout"))

    # device parser
    parser_device = subparsers.add_parser('device')
    device_subparsers = parser_device.add_subparsers()

    # devlist
    dev_list = device_subparsers.add_parser('list', description="Lists meross devices")
    dev_list.set_defaults(func=authenticated_command_executor("async_list_devices"))

    # hub parser
    parser_hub = subparsers.add_parser('hub')
    parser_hub.add_argument("--hub_id", required=True, help="Hub uuid")
    hub_subparsers = parser_hub.add_subparsers()

    # getsubdevices
    subdev_list = hub_subparsers.add_parser('list_sub_devices', description="Lists subdevices for the given hub")
    subdev_list.set_defaults(func=authenticated_command_executor("async_list_hub_subdevices"))

    data = parser.parse_args(sys.argv[1:])
    data.func(data)


if __name__ == '__main__':
    main()
