# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.

# This software is distributed under a commercial license for personal,
# educational, corporate or any other use.
# The software as a whole or any parts of it is prohibited for distribution or
# use without obtaining a license from 3D Control Systems, Inc.

# All software licenses are subject to the 3DPrinterOS terms of use
# (available at https://www.3dprinteros.com/terms-and-conditions/),
# and privacy policy (available at https://www.3dprinteros.com/privacy-policy/)

import os
import json
import hashlib
import logging
import marshal
import pprint
import time
import threading

import config
import paths
from awaitable import Awaitable
from http_client import HTTPClient, HTTPClientPrinterAPIV1


class UserLogin(Awaitable):
    NAME = "user login"
    STORAGE_PATH = os.path.join(paths.CURRENT_SETTINGS_FOLDER, "stuff.bin")
    DEFAULT_PRINTER_PROFILES_PATH = os.path.join(
        os.path.dirname(__file__), "default_printer_profiles.json"
    )
    USER_PRINTER_PROFILES_PATH = os.path.join(
        paths.CURRENT_SETTINGS_FOLDER, "user_printer_profiles.json"
    )
    CACHED_PRINTER_PROFILES_PATH = os.path.join(
        paths.CURRENT_SETTINGS_FOLDER, "cached_printer_profiles.json"
    )
    KEY = (
        b"3dprinteros_used_to_store_password_hashes_in_almost_plain_text_but_no_more!"
        * 3
    )
    APIPRINTER_MODE = "apiprinter"
    STREAMERAPI_MODE = "streamerapi"

    @staticmethod
    def load_stuffbin():  # TODO rename this to show that auth_tokens can be read here too
        try:
            with open(UserLogin.STORAGE_PATH, "rb") as f:
                return marshal.load(f)
        except FileNotFoundError:
            pass
        except:
            return UserLogin.load_new_format_stuff_bin()

    @staticmethod
    def load_new_format_stuff_bin():
        try:
            with open(UserLogin.STORAGE_PATH, "rb") as f:
                data = UserLogin.endecrypt_stuffbin(f.read())
                return json.loads(data)
        except FileNotFoundError:
            pass
        except (IOError, OSError, ValueError, TypeError) as e:
            logger = logging.getLogger(__name__)
            logger.info("Can't read or decode login storage file: " + str(e))
            try:
                UserLogin.logout()
            except:
                pass

    @staticmethod
    def endecrypt_stuffbin(data):
        key = UserLogin.KEY
        while len(key) < len(data):
            key += key
        return bytes([x ^ y for x, y in zip(data, key)])

    @staticmethod
    def validate_login_password(login, password):
        for cred_str in (login, password):
            if cred_str != None and type(cred_str) != str:
                login = None
                password = None
                logger = logging.getLogger(__name__)
                logger.warning(
                    "Invalid login format in stuff.bin. Assuming corrupted file. Removing."
                )
                UserLogin.logout()
                break
        return login, password

    @staticmethod
    def load_login():
        decoded_data = UserLogin.load_stuffbin()
        if (
            decoded_data is None
        ):  # to prevent logout and warnings when no save login file exists
            return None, None
        elif type(decoded_data) in (tuple, list) and len(decoded_data) > 1:
            login, password = decoded_data[:2]
            if type(login) == bytes:
                login = login.decode(errors="ignore", encoding="latin1")
            if type(password) == bytes:
                password = password.decode(errors="ignore", encoding="latin1")
        elif type(decoded_data) == dict:
            login = decoded_data.get("login")
            password = decoded_data.get("password")
        else:
            login, password = None, None
        return UserLogin.validate_login_password(login, password)

    @staticmethod
    def save_login(login, password):
        # to save printer auth tokens
        creds = UserLogin.load_stuffbin()
        if not creds:
            creds = {}
        new_creds = {"login": login, "password": password}
        creds.update(new_creds)
        UserLogin.save_creds(
            UserLogin.endecrypt_stuffbin(json.dumps(creds).encode("utf-8"))
        )

    @staticmethod
    def save_creds(data):  # login storage with some rational paranoia
        logger = logging.getLogger(__name__)
        new_storage_file = os.path.join(paths.CURRENT_SETTINGS_FOLDER, "new_stuff.bin")
        try:
            with open(new_storage_file, "wb") as f:
                f.write(data)
        except (IOError, OSError) as e:
            logger.warning("Error writing login to storage file: " + str(e))
        else:
            try:
                os.rename(new_storage_file, UserLogin.STORAGE_PATH)
                return True
            except (IOError, OSError) as e:
                logger.warning("Error renaming login to storage file: " + str(e))
                try:
                    os.remove(UserLogin.STORAGE_PATH)
                except OSError:
                    pass
                try:
                    os.rename(new_storage_file, UserLogin.STORAGE_PATH)
                    return True
                except (IOError, OSError) as e:
                    logger.warning(
                        "Error renaming login to storage file even after removal of some logs: "
                        + str(e)
                    )
        return False

    @staticmethod
    def logout():
        logger = logging.getLogger(__name__)
        settings = config.get_settings()
        if settings.get("offline_mode", False):
            logger.info("Turning offline mode off in the settings")
            settings["offline_mode"] = False
            config.Config.instance().save_settings(settings)
            return True
        else:
            logger.info("Removing login file")
            try:
                os.remove(UserLogin.STORAGE_PATH)
            except FileNotFoundError:
                logger.info("No login file to remove")
            else:
                logger.info("Login file removed")
                return True
        return False

    @staticmethod
    def apply_settings_mod(settings_mod):
        if settings_mod:
            logger = logging.getLogger(__name__)
            if not isinstance(settings_mod, dict):
                logger.warning(
                    "Server's setting mods is not dict:\n"
                    + pprint.pformat(settings_mod)
                )
            else:
                logger.info("Server's setting mods:\n" + pprint.pformat(settings_mod))
                current_settings = config.get_settings()
                new_settings = config.merge_dictionaries(
                    current_settings, settings_mod, overwrite=True
                )
                config.Config.instance().settings = new_settings
                logger.info("Setting:\n" + pprint.pformat(new_settings))

    @staticmethod
    def load_local_printer_profiles():
        profiles = []
        if config.get_settings()["printer_profiles"]["only_local"]:
            profile_sources = (
                UserLogin.DEFAULT_PRINTER_PROFILES_PATH,
                UserLogin.USER_PRINTER_PROFILES_PATH,
            )
        else:
            profile_sources = (
                UserLogin.DEFAULT_PRINTER_PROFILES_PATH,
                UserLogin.CACHED_PRINTER_PROFILES_PATH,
                UserLogin.USER_PRINTER_PROFILES_PATH,
            )
        for path in profile_sources:
            try:
                with open(path) as f:
                    profiles = UserLogin.merge_profiles(profiles, json.load(f))
            except FileNotFoundError:
                pass
            except Exception as e:
                logger = logging.getLogger(__name__)
                logger.warning("Error loading profile file %s: %s" % (path, str(e)))
        return profiles

    @staticmethod
    def save_profiles(profiles, filename=None):
        if not filename:
            filename = UserLogin.CACHED_PRINTER_PROFILES_PATH
        try:
            if not isinstance(profiles, str):
                profiles = json.dumps(
                    profiles, sort_keys=True, indent=4, separators=(",", ": ")
                )
            with open(filename, "w") as f:
                f.write(profiles)
        except (OSError, ValueError) as e:
            logging.getLogger(__name__).warning(
                "Error saving profile file %s: %s" % (profiles, str(e))
            )

    @staticmethod
    def merge_profiles(base_profiles, updating_profiles):
        base_profiles_dict = {}
        for profile in base_profiles:
            alias = profile.get("alias")
            if not alias:
                logging.getLogger(__name__).warning(
                    "Invalid profile(no alias):\n%s", pprint.pformat(profile)
                )
            elif "name" not in profile:
                logging.getLogger(__name__).warning(
                    "Invalid profile(no name):\n%s", pprint.pformat(profile)
                )
            elif not profile.get("vids_pids") and "v2" not in profile:
                logging.getLogger(__name__).warning(
                    "Invalid profile(no vids_pids or v2):\n%s", pprint.pformat(profile)
                )
            else:
                base_profiles_dict[alias] = profile
        updating_profiles_dict = {}
        if updating_profiles:
            for profile in updating_profiles:
                alias = profile.get("alias")
                if not alias:
                    logging.getLogger(__name__).warning(
                        "Invalid profile(no alias):\n%s", pprint.pformat(profile)
                    )
                elif "name" not in profile:
                    logging.getLogger(__name__).warning(
                        "Invalid profile(no name):\n%s", pprint.pformat(profile)
                    )
                elif not profile.get("vids_pids") and "v2" not in profile:
                    logging.getLogger(__name__).warning(
                        "Invalid profile(no vids_pids or v2):\n%s",
                        pprint.pformat(profile),
                    )
                else:
                    updating_profiles_dict[alias] = profile
        return list(
            config.merge_dictionaries(
                base_profiles_dict, updating_profiles_dict, overwrite=True
            ).values()
        )

    @staticmethod
    def load_printer_auth_tokens():
        decoded_data = UserLogin.load_stuffbin()
        if type(decoded_data) == dict:
            auth_tokens = decoded_data.get("auth_tokens")
            if type(auth_tokens) == list:
                return auth_tokens
        return []

    def __init__(self, parent, auto_login=True, retry_in_background=False):
        self.logger = logging.getLogger(__class__.__name__)
        self.user_token = None
        self.parent = parent
        self.login = None
        self.macaddr = ""
        self.profiles = self.load_local_printer_profiles()
        config.Config.instance().set_profiles(self.profiles)
        self.profiles_sha256 = self.get_cloud_profiles_cache()
        self.auth_tokens = []
        self.login_lock = threading.RLock()
        self.retry_in_background = retry_in_background
        self.login_retry_thread = None
        self.got_network_connection = False
        self.http_connection = None
        if config.get_settings()["protocol"]["user_login"]:
            self.login_mode = self.STREAMERAPI_MODE
            self.logger.info("Operating in user token mode")
        else:
            self.login_mode = self.APIPRINTER_MODE
            self.logger.info("Operating in auth token mode")
        if auto_login and not self.parent or not self.parent.offline_mode:
            if not self.login_from_saved_creds(exit_no_saved=False):
                if retry_in_background:
                    self.login_retry_thread = threading.Thread(
                        target=self.retry_login_until_success
                    )
                    self.login_retry_thread.start()
                else:
                    self.retry_login_until_success(exit_no_saved=True)
        Awaitable.__init__(self, parent)

    def login_from_saved_creds(self, exit_no_saved=False):
        with self.login_lock:
            if self.login_mode == self.APIPRINTER_MODE:
                self.auth_tokens = self.load_printer_auth_tokens()
                self.logger.info("Getting profiles without user login")
                self.http_connection = HTTPClientPrinterAPIV1(
                    self.parent, exit_on_fail=True
                )
                profiles = []
                if self.http_connection.connection:
                    self.got_network_connection = True
                    if config.get_settings()["printer_profiles"]["get_updates"]:
                        profiles = self.http_connection.pack_and_send(
                            HTTPClientPrinterAPIV1.PRINTER_PROFILES
                        )
                        if profiles:
                            self.save_profiles(profiles)
                else:
                    self.got_network_connection = False
                self.http_connection.close()
                self.update_profiles()
                return True
            else:
                if self.user_token:
                    return True
                login, password = UserLogin.load_login()
                self.http_connection = HTTPClient(self.parent, exit_on_fail=True)
                error = None
                if self.http_connection.connection:
                    self.got_network_connection = True
                    if login:
                        error = self.login_as_user(
                            login, password, save_password_flag=False
                        )
                        if error:
                            self.logger.info(str(error))
                    self.http_connection.close()
                else:
                    self.got_network_connection = False
                    error = "No network"
                if not error:
                    if login:
                        return True
                    else:
                        return exit_no_saved
                return False

    def login_as_user(
        self, login=None, password=None, disposable_token=None, save_password_flag=True
    ):
        with self.login_lock:
            if not login:
                return 0, "Empty login"
            if password is None and not disposable_token:
                return 0, "Empty password"
            if not self.http_connection:
                self.http_connection = HTTPClient(self.parent, exit_on_fail=True)
            answer = self.http_connection.pack_and_send(
                HTTPClient.USER_LOGIN,
                login,
                password,
                disposable_token=disposable_token,
                profiles_sha256=self.profiles_sha256,
            )
            if answer:
                settings_mod = answer.get("settings_mod")
                self.apply_settings_mod(settings_mod)
                error = answer.get("error", None)
                if error:
                    self.logger.warning("Error processing user_login " + str(error))
                    self.logger.error("Login rejected")
                    code, message = error["code"], error["message"]
                    if code == 3:
                        if self.logout() and self.parent:
                            self.logger.info("Initiating application restart")
                            self.parent.restart_flag = True
                            self.parent.stop_flag = True
                    return code, message
                if login and password is not None and save_password_flag:
                    self.save_login(login, password)
                profiles = answer.get("all_profiles", [])
                if (
                    profiles
                    and config.get_settings()["printer_profiles"]["get_updates"]
                ):
                    try:
                        profiles_sha256 = hashlib.sha256(
                            str(profiles).encode("ascii", errors="ignore")
                        ).hexdigest()
                    except:
                        profiles_sha256 = "hash error"
                    self.logger.info(
                        "Received cloud profiles with sha256: %s", profiles_sha256
                    )
                    self.save_profiles(profiles)
                    if isinstance(profiles, str):
                        try:
                            profiles = json.loads(profiles)
                        except:
                            profiles = []
                            self.logger.error(
                                "Server's user_login response got invalid printer profiles - not json"
                            )
                    elif not isinstance(profiles, list):
                        self.logger.error(
                            "Server's user_login response got invalid printer profiles - not json list"
                        )
                        profiles = []
                self.update_profiles()
                self.macaddr = self.http_connection.host_id
                if login:
                    self.login = login
                else:
                    login_name = answer.get("user_login")
                    if login_name:
                        self.login = login_name
                    else:
                        self.login = "Temporary login"
                user_token = answer.get("user_token")
                if not user_token:
                    raise ValueError("Server returned empty user token")
                self.user_token = user_token
                self.logger.info("Successful login from user %s" % self.login)

    def save_printer_auth_token(self, usb_info=None, auth_token=None):
        if usb_info and auth_token:
            for index, creds_pair in enumerate(self.auth_tokens):
                if creds_pair and type(creds_pair) == tuple:
                    existing_pr_id = creds_pair[0]
                    if (
                        isinstance(existing_pr_id, dict)
                        and existing_pr_id["VID"] == usb_info["VID"]
                        and existing_pr_id["PID"] == usb_info["PID"]
                        and existing_pr_id["SNR"] == usb_info["SNR"]
                    ):
                        self.auth_tokens[index] = (usb_info, auth_token)
                        break
            else:
                self.auth_tokens.append((usb_info, auth_token))
        # to save username and password for user_login mode
        creds = UserLogin.load_stuffbin()
        if not creds:
            creds = {}
        creds.update({"auth_tokens": self.auth_tokens})
        return self.save_creds(
            self.endecrypt_stuffbin(json.dumps(creds).encode("utf-8"))
        )

    def forget_auth_tokens(self):
        self.auth_tokens = []
        self.save_printer_auth_token()

    def update_profiles(self, profiles=None):
        if not profiles:
            profiles = self.load_local_printer_profiles()
        self.profiles = profiles
        self.logger.info("Got profiles for %d printers" % len(self.profiles))
        config.Config.instance().set_profiles(self.profiles)

    def check_function(self):
        return bool(
            self.user_token
            or self.auth_tokens
            or not config.get_settings()["protocol"]["user_login"]
            or (self.parent and getattr(self.parent, "offline_mode", False))
        )

    def retry_login_until_success(self, exit_no_saved=False):
        while not self.parent or not getattr(self.parent, "stop_flag"):
            if self.login_from_saved_creds(exit_no_saved) or self.check_function():
                return
            time.sleep(2)

    def get_cloud_profiles_cache(self):
        try:
            with open(self.CACHED_PRINTER_PROFILES_PATH, encoding="ascii") as f:
                profiles_string = f.read()
                cloud_profiles_sha256 = hashlib.sha256(
                    profiles_string.encode("ascii", errors="ignore")
                ).hexdigest()
                self.logger.info(
                    "Current printers profiles sha256: " + cloud_profiles_sha256
                )
        except FileNotFoundError:
            cloud_profiles_sha256 = ""
        except Exception as e:
            self.logger.exception("Error calculating printer profiles hash:" + str(e))
            cloud_profiles_sha256 = ""
        return cloud_profiles_sha256


if __name__ == "__main__":
    import sys

    class FakeApp:
        def __init__(self):
            self.offline_mode = False
            self.stop_flag = False

    login, _ = UserLogin.load_login()
    cloud_auth_res = "Fail"
    if login:
        ul = UserLogin(FakeApp(), retry_in_background=False)
        if ul.login:
            cloud_auth_res = "Success"
    if "--login" in sys.argv:
        if not login:
            sys.exit(1)
        print(login)
    elif "--auth" in sys.argv:
        if not cloud_auth_res:
            sys.exit(2)
        print(cloud_auth_res)
    else:
        if not login:
            print("Login: no")
        else:
            print("Login: " + login)
        print("Cloud authorization: " + cloud_auth_res)
