#
# Copyright 2024 Splunk Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Credentials Management for REST Endpoint
"""


import json
import urllib.parse

from solnlib.credentials import CredentialManager, CredentialNotExistException

from .error import RestError
from .util import get_base_app_name

__all__ = [
    "RestCredentialsContext",
    "RestCredentials",
]


class RestCredentialsContext:
    """
    Credentials' context, including realm, username and password.
    """

    REALM = "__REST_CREDENTIAL__#{base_app}#{endpoint}"

    def __init__(self, endpoint, name, *args, **kwargs):
        self._endpoint = endpoint
        self._name = name
        self._args = args
        self._kwargs = kwargs

    def realm(self):
        """
        RestCredentials context ``realm``.
        :return:
        """
        return self.REALM.format(
            base_app=get_base_app_name(),
            endpoint=self._endpoint.internal_endpoint.strip("/"),
        )

    def username(self):
        """
        RestCredentials context ``username``.
        :return:
        """
        return self._name

    def dump(self, data):
        """
        RestCredentials context ``password``.
        Dump data to string.
        :param data: data to be encrypted
        :type data: dict
        :return:
        """
        return json.dumps(data)

    def load(self, string):
        """
        RestCredentials context ``password``.
        Load data from string.
        :param string: data has been decrypted
        :type string: str
        :return:
        """
        try:
            return json.loads(string)
        except ValueError:
            raise RestError(500, "Fail to load encrypted string, invalid JSON")


class RestCredentials:
    """
    Credential Management stored in passwords.conf
    """

    # Changed password constant to six '*' to make it consistent with solnlib password constant
    PASSWORD = "******"
    EMPTY_VALUE = ""

    def __init__(self, splunkd_uri, session_key, endpoint):
        self._splunkd_uri = splunkd_uri
        self._splunkd_info = urllib.parse.urlparse(self._splunkd_uri)
        self._session_key = session_key
        self._endpoint = endpoint
        self._realm = "__REST_CREDENTIAL__#{base_app}#{endpoint}".format(
            base_app=get_base_app_name(),
            endpoint=self._endpoint.internal_endpoint.strip("/"),
        )

    def get_encrypted_field_names(self, name):
        return [x.name for x in self._endpoint.model(name).fields if x.encrypted]

    def encrypt_for_create(self, name, data):
        """
            force to encrypt all fields that need to be encrypted
            used for create scenarios
        :param name:
        :param data:
        :return:
        """
        encrypted_field_names = self.get_encrypted_field_names(name)
        encrypting = {}
        for field_name in encrypted_field_names:
            if field_name in data and data[field_name]:
                # if it exist in data and it's not empty,
                # encrypt it and set original value as "****..."
                encrypting[field_name] = data[field_name]
                data[field_name] = self.PASSWORD

        if encrypting:
            # only save credential when the stanza is existing in
            # passwords.conf or encrypting data is not empty
            self._set(name, encrypting)

    def encrypt_for_update(self, name, data):
        """

        :param name:
        :param data:
        :return:
        """
        encrypted_field_names = self.get_encrypted_field_names(name)
        encrypting = {}
        if not encrypted_field_names:
            # return if there are not encrypted fields
            return
        for field_name in encrypted_field_names:
            if field_name in data and data[field_name]:
                if data[field_name] != self.PASSWORD:
                    # if the field in data and not empty and it's not '*******', encrypted it
                    encrypting[field_name] = data[field_name]
                    data[field_name] = self.PASSWORD
                else:
                    # if the field value is '******', keep the original value
                    try:
                        original_clear_password = self._get(name)
                    except CredentialNotExistException:
                        original_clear_password = None
                    if original_clear_password and original_clear_password.get(
                        field_name
                    ):
                        encrypting[field_name] = original_clear_password[field_name]
                    else:
                        # original password does not exist, use '******' as password
                        encrypting[field_name] = data[field_name]
            elif field_name in data and not data[field_name]:
                data[field_name] = ""
            else:
                # field not in data
                # if the optional encrypted field is not passed, keep original if it exist
                try:
                    original_clear_password = self._get(name)
                except CredentialNotExistException:
                    original_clear_password = None
                if original_clear_password and original_clear_password.get(field_name):
                    encrypting[field_name] = original_clear_password[field_name]
                    data[field_name] = self.PASSWORD

        if encrypting:
            self._set(name, encrypting)
        else:
            self.delete(name)

    def decrypt_for_get(self, name, data):
        """
            encrypt password if conf changed and return data that needs to write back to conf
        :param name:
        :param data:
        :return:
        """
        data_need_write_to_conf = dict()
        # password dict needs to be encrypted
        encrypting = dict()
        encrypted_field_names = self.get_encrypted_field_names(name)
        if not encrypted_field_names:
            return
        try:
            # try to get clear password for the entity
            clear_password = self._get(name)
            # password exist for the entity
            for field_name in encrypted_field_names:
                if field_name in data and data[field_name]:
                    if data[field_name] != self.PASSWORD:
                        # if the field exist in data and not equals to '*******'
                        # add to dict to be encrypted, else treat it as unchanged
                        encrypting[field_name] = data[field_name]
                        data_need_write_to_conf[field_name] = self.PASSWORD

                    else:
                        # get clear password for the field
                        data[field_name] = clear_password[field_name]
                        encrypting[field_name] = clear_password[field_name]

            if encrypting and clear_password != encrypting:
                # update passwords.conf if password changed
                self._set(name, encrypting)
        except CredentialNotExistException:
            # password does not exist for the entity
            for field_name in encrypted_field_names:
                if field_name in data and data[field_name]:
                    if data[field_name] != self.PASSWORD:
                        # if the field exist in data and not equals to '*******'
                        # add to dict to be encrypted
                        encrypting[field_name] = data[field_name]
                        data_need_write_to_conf[field_name] = self.PASSWORD
                    else:
                        # treat '*******' as password
                        encrypting[field_name] = self.PASSWORD

            if encrypting:
                # set passwords.conf if encrypting data is not empty
                self._set(name, encrypting)

        return data_need_write_to_conf

    def encrypt(self, name, data):
        """

        :param name:
        :param data:
        :return:
        """
        # Check if encrypt is needed
        model = self._endpoint.model(name)
        need_encrypting = all(field.encrypted for field in model.fields)
        if not need_encrypting:
            return
        try:
            encrypted = self._get(name)
            existing = True
        except CredentialNotExistException:
            encrypted = {}
            existing = False
        encrypting = self._filter(name, data, encrypted)
        self._merge(name, encrypted, encrypting)
        if existing or encrypting:
            # only save credential when the stanza is existing in
            # passwords.conf or encrypting data is not empty
            self._set(name, encrypting)

    def decrypt(self, name, data):
        """

        :param name:
        :param data:
        :return: If the passwords.conf is updated, masked data.
            Else, None.
        """
        try:
            # clear password object loads from json
            encrypted = self._get(name)
            existing = True
        except CredentialNotExistException:
            encrypted = {}
            existing = False
        # get fields to be encrypted
        encrypting = self._filter(name, data, encrypted)
        self._merge(name, encrypted, encrypting)
        if existing or encrypting:
            # only save credential when the stanza is existing in
            # passwords.conf or encrypting data is not empty
            self._set(name, encrypting)
        data.update(encrypting)
        return encrypted

    def decrypt_all(self, data):
        """
        :param data:
        :return: changed stanza list
        """
        credential_manager = CredentialManager(
            self._session_key,
            owner=self._endpoint.user,
            app=self._endpoint.app,
            realm=self._realm,
            scheme=self._splunkd_info.scheme,
            host=self._splunkd_info.hostname,
            port=self._splunkd_info.port,
        )
        all_passwords = credential_manager.get_clear_passwords_in_realm()
        realm_passwords = [x for x in all_passwords if x["realm"] == self._realm]
        return self._merge_passwords(data, realm_passwords)

    @staticmethod
    def _delete_empty_value_for_dict(dct):
        empty_value_names = [k for k, v in dct.items() if v == ""]
        for k in empty_value_names:
            del dct[k]

    def _merge_passwords(self, data, passwords):
        """
        return if some fields need to write with new "******"
        """
        # merge clear passwords to response data
        changed_item_list = []

        password_dict = {
            pwd["username"]: json.loads(pwd["clear_password"]) for pwd in passwords
        }
        # existed passwords models: previously has encrypted value
        existing_encrypted_items = [x for x in data if x["name"] in password_dict]

        # previously has no encrypted value
        not_encrypted_items = [x for x in data if x["name"] not in password_dict]

        # For model that password existed
        # 1.Password changed: Update it and add to changed_item_list
        # 2.Password unchanged: Get the password and update the response data
        for existed_model in existing_encrypted_items:
            name = existed_model["name"]
            clear_password = password_dict[name]
            need_write_magic_pwd = False
            need_write_back_pwd = False
            for k, v in clear_password.items():
                # make sure key exist in model content
                if k in existed_model["content"]:
                    if existed_model["content"][k] == self.PASSWORD:
                        # set existing as raw value
                        existed_model["content"][k] = v
                    elif existed_model["content"][k] == "":
                        # mark to delete it
                        clear_password[k] = ""
                        need_write_back_pwd = True
                        continue
                    else:
                        need_write_magic_pwd = True
                        need_write_back_pwd = True
                        clear_password[k] = existed_model["content"][k]
                else:
                    # mark to delete it
                    clear_password[k] = ""
                    need_write_back_pwd = True

            # update the password storage
            if need_write_magic_pwd:
                changed_item_list.append(existed_model)

            if need_write_back_pwd:
                self._delete_empty_value_for_dict(clear_password)
                if clear_password:
                    self._set(name, clear_password)
                else:
                    # there's no any pwd any more, directly delete it.
                    self.delete(name)

        # For other models, encrypt the password and return
        for other_model in not_encrypted_items:
            name = other_model["name"]
            content = other_model["content"]
            encrypted_field_names = self.get_encrypted_field_names(name)
            clear_password = {}
            for field_name in encrypted_field_names:
                # make sure key exist in model content
                if field_name in content and content[field_name] != "":
                    clear_password[field_name] = content[field_name]
            if clear_password:
                self._set(name, clear_password)

        changed_item_list.extend(not_encrypted_items)
        return changed_item_list

    def delete(self, name):
        context = RestCredentialsContext(self._endpoint, name)
        mgr = self._get_manager(context)
        try:
            mgr.delete_password(user=context.username())
        except CredentialNotExistException:
            pass

    def _set(self, name, credentials):
        if credentials is None:
            return
        context = RestCredentialsContext(self._endpoint, name)
        mgr = self._get_manager(context)
        mgr.set_password(user=context.username(), password=context.dump(credentials))

    def _get(self, name):
        context = RestCredentialsContext(self._endpoint, name)
        mgr = self._get_manager(context)
        string = mgr.get_password(user=context.username())
        return context.load(string)

    def _filter(self, name, data, encrypted_data):
        model = self._endpoint.model(name)
        encrypting_data = {}
        for field in model.fields:
            if not field.encrypted:
                # remove non-encrypted fields
                if field.name in encrypted_data:
                    del encrypted_data[field.name]
                continue
            if field.name not in data:
                # ignore un-posted fields
                continue
            if data[field.name] == self.PASSWORD:
                # ignore already-encrypted fields
                continue
            if data[field.name] != self.EMPTY_VALUE:
                encrypting_data[field.name] = data[field.name]
                # non-empty fields
                data[field.name] = self.PASSWORD
                if field.name in encrypted_data:
                    del encrypted_data[field.name]
        return encrypting_data

    def _merge(self, name, encrypted, encrypting):
        model = self._endpoint.model(name)
        for field in model.fields:
            if field.encrypted is False:
                continue

            val_encrypting = encrypting.get(field.name)
            if val_encrypting:
                encrypted[field.name] = self.PASSWORD
                continue
            elif val_encrypting == self.EMPTY_VALUE:
                del encrypting[field.name]
                encrypted[field.name] = self.EMPTY_VALUE
                continue

            val_encrypted = encrypted.get(field.name)
            if val_encrypted:
                encrypting[field.name] = val_encrypted
                del encrypted[field.name]

    def _get_manager(self, context):
        return CredentialManager(
            self._session_key,
            owner=self._endpoint.user,
            app=self._endpoint.app,
            realm=context.realm(),
            scheme=self._splunkd_info.scheme,
            host=self._splunkd_info.hostname,
            port=self._splunkd_info.port,
        )
