import json
from logging import Logger  # Only used for type hinting
from typing import Any, Literal, Optional

import requests

from poe_servicenow import get_logger, log_it
from poe_servicenow.utils import get_user_email_from_aws


class ServiceNowClient:
    """ServiceNowClient provides methods for interacting with the ServiceNow API."""

    def __init__(
        self,
        username: str,
        password: str,
        sys_id: str,
        base_url: str = "https://pearsonnow.service-now.com/api",
        myaccess_management_id: str = "a3fdc9781b0621d01c87ed79b04bcb07",
        http_session: requests.Session | Any = None,
        logger: Logger | None = None,
    ):
        """Initializes a ServiceNow client

        Parameters
        ----------
        username : str
            The ServiceNow username for authentication.

        password : str
            The ServiceNow password for authentication.

        sys_id : str
            The sys_id of the ServiceNow user account.

        base_url : str
            (optional) The base URL used for API requests.
            (default) https://pearsonnow.service-now.com/api

        myaccess_management_id : str
            (optional) The ID of the myAccess catalog item.
            (default) a3fdc9781b0621d01c87ed79b04bcb07

        http_session : requests.Session
            (optional) The requests Session used by a ServiceNow object. If unspecified,
            a new requests.Session will be created.

        logger : logging.Logger
            (optional) The logger used by a ServiceNow object. If unspecified,
            a new logger will be created.
        """
        assert base_url, f"must include a base URL when initializing {self.__class__.__name__}"
        self.__sys_id: str = sys_id
        self.__base_url: str = base_url
        self.__myaccess_management_id: str = myaccess_management_id
        self.__http_session: requests.Session | Any = http_session or requests.Session()
        self.__logger: Logger = logger or get_logger()

        self.http_session.auth = (username, password)

    @property
    def sys_id(self) -> str:
        return self.__sys_id

    @property
    def base_url(self) -> str:
        return self.__base_url

    @property
    def myaccess_management_id(self) -> str:
        return self.__myaccess_management_id

    @property
    def http_session(self) -> requests.Session | Any:
        return self.__http_session

    @property
    def _logger(self) -> Logger:
        return self.__logger

    @log_it
    def get_user_by_email(self, email: str) -> dict:
        """
        Retrieve a ServiceNow user record from the sys_user table by email address.

        Parameters
        ----------
        email : str
            The email address of the user to search for in ServiceNow.

        Returns
        -------
        dict
            The first user record matching the provided email address.

            {
                'active': 'true',
                'avatar': '',
                'average_daily_fte': '',
                'building': '',
                'calendar_integration': '1',
                'city': 'Durham',
                'company': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/core_company/37a42e4f97bd1d106013b280f053af5e',
                    'value': '37a42e4f97bd1d106013b280f053af5e'
                },
                'correlation_id': '',
                'cost_center': '',
                'country': 'US',
                'date_format': '',
                'department': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/cmn_department/b46a085c4747d51007b31a7c736d4326',
                    'value': 'b46a085c4747d51007b31a7c736d4326'
                },
                'email': 'jonathan.morris@pearson.com',
                'employee_number': '3200031',
                'enable_multifactor_authn': 'false',
                'failed_attempts': '0',
                'fax': '',
                'federated_id': 'DAID1axtSLm9Wz27tb3NHxW+JMESBe5AJBgTgT1P8L0=',
                'first_name': 'Jonathan',
                'gender': '',
                'home_phone': '',
                'internal_integration_user': 'false',
                'introduction': '',
                'last_login': '2025-08-04',
                'last_login_time': '2025-08-04 19:56:05',
                'last_name': 'Morris',
                'limit_concurrent_sessions': 'false',
                'location': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/cmn_location/e4946e0b975ad9500fd538ffe153af15',
                    'value': 'e4946e0b975ad9500fd538ffe153af15'
                },
                'manager': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user/41d3a8d09703d51091e1fffe2153afe6',
                    'value': '41d3a8d09703d51091e1fffe2153afe6'
                },
                'middle_name': '',
                'mobile_phone': '',
                'name': 'Jonathan Morris',
                'notification': '2',
                'phone': '',
                'photo': '',
                'preferred_language': '',
                'schedule': '',
                'source': 'ldap:CN=Morris\\, Jonathan,OU=Users,OU=Provisioned,OU=North America,DC=PEROOT,DC=com',
                'sso_source': '',
                'state': 'NC',
                'street': '',
                'sys_class_name': 'sys_user',
                'sys_created_by': 'ambadas.taklikar',
                'sys_created_on': '2022-11-04 16:14:56',
                'sys_domain': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user_group/global',
                    'value': 'global'
                },
                'sys_domain_path': '/',
                'sys_id': '4e33e49897cf951091e1fffe2153af22',
                'sys_mod_count': '1149',
                'sys_tags': '',
                'sys_updated_by': 'ambatakl',
                'sys_updated_on': '2025-08-05 04:24:45',
                'time_format': '',
                'time_zone': 'US/Central',
                'title': 'Observability Engineer',
                'transaction_log': '',
                'u_bu_short_code': 'CST',
                'u_country_code': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/core_country/dd38b7111b121100763d91eebc0713f5',
                    'value': 'dd38b7111b121100763d91eebc0713f5'
                },
                'u_desk_location': '',
                'u_employee_type': 'Regular Employee',
                'u_fusion_cost_center': '251305',
                'u_fusion_role': '1,',
                'u_last_refreshed': '2025-08-04 22:54:45',
                'u_legalemployer': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/u_legal_employer/2c9853e31be29d50326a21f6b04bcb0c',
                    'value': '2c9853e31be29d50326a21f6b04bcb0c'
                },
                'u_objectguid': 'w6fHJ4MmhUyEqFxSkCq5IQ==',
                'user_name': 'UMORR27',
                'vip': 'false',
                'web_service_access_only': 'false',
                'zip': '27703'
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        AssertionError
            If the number of results is != 1.
        """
        self._logger.info("Searching for user with email %s", email)

        result: list[dict] = self._make_request(
            "get", f"{self.base_url}/now/table/sys_user", params={"sysparm_query": f"email={email}"}
        ).json()["result"]

        assert len(result) == 1, f"Expected one user with email {email}, found {len(result)}"
        self._logger.info("Found user in ServiceNow with email address: %s", email)

        return result[0]

    @log_it
    def get_application_by_name(self, application_name: str) -> dict:
        """
        Retrieve a ServiceNow myAccess application record from the x_pepl_myaccess_application table
        by application name.

        Parameters
        ----------
        application_name : str
            The name of the myAccess application to search for in ServiceNow.

        Returns
        -------
        dict
            The first application record matching the provided application name.

            {
                'sys_created_by': 'midserver',
                'sys_created_on': '2022-07-27 05:10:09',
                'sys_id': 'f01c8bb31b7c1d10d6ccb8c8dc4bcb37',
                'sys_mod_count': '2',
                'sys_tags': '',
                'sys_updated_by': 'UCRONLE',
                'sys_updated_on': '2024-07-02 16:39:54',
                'u_active': 'true',
                'u_application_owner': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user/41d3a8d09703d51091e1fffe2153afe6',
                    'value': '41d3a8d09703d51091e1fffe2153afe6'
                },
                'u_application_owner_group': '',
                'u_areas': 'New Relic',
                'u_edna_pact_pso_prime': 'false',
                'u_myaccess': 'true'
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        AssertionError
            If the number of results is != 1.
        """
        self._logger.info("Searching for myAccess application %s", application_name)

        result: list[dict] = self._make_request(
            "get",
            f"{self.base_url}/now/table/x_pepl_myaccess_application",
            params={"sysparm_query": f"u_areas={application_name}"},
        ).json()["result"]

        assert (
            len(result) == 1
        ), f"Expected 1 application named {application_name}, found {len(result)}"
        self._logger.info("Found application in ServiceNow with name: %s", application_name)

        return result[0]

    @log_it
    def get_application_category_by_name(self, application_sys_id: str, category_name: str) -> dict:
        """
        Retrieve a ServiceNow myAccess application category record from the
        x_pepl_myaccess_application_categories table by application sys_id and category name.
        If you happen to know the sys_id of the application, you can input it directly. Otherwise,
        this method is commonly used in conjunction with the `get_snow_application_by_name` method.

        Parameters
        ----------
        application_sys_id : str
            The sys_id of the myAccess application in ServiceNow.
        category_name : str
            The name of the category to search for within the application.

        Returns
        -------
        dict
            The application category record matching the provided application sys_id and category name.

            {
                'sys_created_by': 'midserver',
                'sys_created_on': '2022-07-27 05:10:09',
                'sys_id': '7c1c8bb31b7c1d10d6ccb8c8dc4bcb37',
                'sys_mod_count': '0',
                'sys_tags': '',
                'sys_updated_by': 'midserver',
                'sys_updated_on': '2022-07-27 05:10:09',
                'u_active': 'true',
                'u_areas': { # myAccess Application
                    'link': 'https://pearsonnow.service-now.com/api/now/table/x_pepl_myaccess_application/f01c8bb31b7c1d10d6ccb8c8dc4bcb37',
                    'value': 'f01c8bb31b7c1d10d6ccb8c8dc4bcb37'
                },
                'u_aws_account_name': '',
                'u_aws_account_number': '',
                'u_role_group': 'Users' # Application Category name
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        AssertionError
            If the number of results is != 1.
        """
        self._logger.info("Searching for myAccess application category %s", category_name)

        result: list[dict] = self._make_request(
            "get",
            f"{self.base_url}/now/table/x_pepl_myaccess_application_categories",
            params={"sysparm_query": f"u_areas={application_sys_id}^u_role_group={category_name}"},
        ).json()["result"]

        assert len(result) == 1, f"Expected 1 category named {category_name}, found {len(result)}"
        self._logger.info("Found category in ServiceNow with name: %s", category_name)

        return result[0]

    @log_it
    def get_role_by_name(
        self,
        role_name: str,
        application_name: Optional[str] = None,
        category_name: Optional[str] = None,
    ) -> dict:
        """
        Retrieve a ServiceNow myAccess role by querying the x_pepl_myaccess_category_role_names
        table by name, and optionally by application name and category name.

        Parameters
        ----------
        role_name : str
            The name of the role to search for in ServiceNow.
        application_name : str, optional
            The name of the myAccess application containing the role. Used to help identify the
            specific role if there's multiple roles with the same name across multiple applications.
        application_category : str, optional
            The myAccess category within an application for the role. Used to help identify the
            specific role if there are multiple roles with the same name across different
            application categories.

        Returns
        -------
        dict
            The first role record matching the provided criteria.

            {
                'sys_created_by': 'orch_midserver',
                'sys_created_on': '2025-05-05 21:46:46',
                'sys_id': 'e8b2ef8e83d12a10752999226daad327',
                'sys_mod_count': '1',
                'sys_tags': '',
                'sys_updated_by': 'USCHMJE',
                'sys_updated_on': '2025-05-14 14:13:21',
                'u_active': 'true',
                'u_ad_group_name': 'WW New Relic Users aFSO_panext-nonprd',
                'u_approval_group_name': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user_group/57dcfad5dbba2010a04861bbd39619d3',
                    'value': '57dcfad5dbba2010a04861bbd39619d3'
                },
                'u_approval_group_name_2': '',
                'u_full_path_name': 'aFSO_panext-nonprd / Users / New Relic',
                'u_previous_ad_group': 'WW New Relic Users aFSO_panext-nonprd',
                'u_request': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sc_req_item/22b4024afb5522106f6cfc32beefdc3a',
                    'value': '22b4024afb5522106f6cfc32beefdc3a'
                },
                'u_requires_manager_approval': 'false',
                'u_requires_manager_approval_for_removal_of_access': 'false',
                'u_role_description': 'Advanced FSO access with permission to create/edit/modify entities. This is a paid subscription.',
                'u_role_group': { # Application Category
                    'link': 'https://pearsonnow.service-now.com/api/now/table/x_pepl_myaccess_application_categories/7c1c8bb31b7c1d10d6ccb8c8dc4bcb37',
                    'value': '7c1c8bb31b7c1d10d6ccb8c8dc4bcb37'
                },
                'u_role_justification': 'This role is used for members of teams supporting panext-nonprd products who must create/edit/modify New Relic entities.',
                'u_role_name': 'aFSO_panext-nonprd',
                'u_task_assignment_group': '',
                'u_task_assignment_group_2': ''
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        AssertionError
            If the number of results is != 1.
        """
        self._logger.info("Searching for myAccess role %s", role_name)

        query_params: str = f"u_role_name={role_name}"
        if application_name:
            query_params += f"^u_role_group.u_areas.u_areas={application_name}"
        if category_name:
            query_params += f"^u_role_group.u_role_group={category_name}"

        result: list[dict] = self._make_request(
            "get",
            f"{self.base_url}/now/table/x_pepl_myaccess_category_role_names",
            params={"sysparm_query": query_params},
        ).json()["result"]

        assert len(result) == 1, f"Expected 1 role named {role_name}, found {len(result)}"
        self._logger.info("Found myAccess role in ServiceNow with name: %s", role_name)

        return result[0]

    @log_it
    def get_approval_group_by_name(self, approval_group_name: str) -> dict:
        """
        Retrieve a ServiceNow approval group by group name by querying the sys_user_group table.

        Parameters
        ----------
        approval_group_name : str
            The name of the approval group to search for in ServiceNow.

        Returns
        -------
        dict
            The approval group record matching the provided group name.

            {
                'active': 'true',
                'average_daily_fte': '',
                'cost_center': '',
                'default_assignee': '',
                'description': 'Requests for supported services.',
                'email': 'POE@grp.pearson.com',
                'exclude_manager': 'false',
                'hourly_rate': '0',
                'include_members': 'false',
                'manager': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user/41d3a8d09703d51091e1fffe2153afe6',
                    'value': '41d3a8d09703d51091e1fffe2153afe6'
                },
                'name': 'Pearson Observability Engineering',
                'parent': '',
                'points': '0',
                'source': '',
                'sys_created_by': 'BOLEJE',
                'sys_created_on': '2021-07-29 20:11:29',
                'sys_id': '7321c5531b697050c6a6a608b04bcb9a',
                'sys_mod_count': '15',
                'sys_tags': '',
                'sys_updated_by': 'admin',
                'sys_updated_on': '2024-11-06 22:23:28',
                'type': '1cb8ab9bff500200158bffffffffff62,458de4f067671300dbfdbb2d07415ad6',
                'u_business_unit': '',
                'u_department': '',
                'u_team_leader': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user/4e33e49897cf951091e1fffe2153af22',
                    'value': '4e33e49897cf951091e1fffe2153af22'
                }
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        AssertionError
            If the number of results is != 1.
        """
        self._logger.info("Searching for SNOW approval group %s", approval_group_name)

        result: list[dict] = self._make_request(
            "get",
            f"{self.base_url}/now/table/sys_user_group",
            params={"sysparm_query": f"name={approval_group_name}"},
        ).json()["result"]

        assert (
            len(result) == 1
        ), f"Expected 1 group named {approval_group_name}, found {len(result)}"
        self._logger.info(
            "Found myAccess approval group in ServiceNow with name: %s", approval_group_name
        )

        return result[0]

    @log_it
    def create_new_role_in_existing_category(
        self,
        role_name: str,
        application_name: str,
        application_category: str,
        access_require_manager_approval: bool,
        removal_require_manager_approval: bool,
        role_description: str,
        role_justification: str,
        approval_group_names: Optional[list[str]] = None,
        request_for_self: Optional[bool] = False,
    ) -> dict:
        """
        Create a new role in an existing application category in ServiceNow myAccess by ordering the
        myAccess catalog item and setting the `manage_task` field to "New Application Role".
        The ID of the myAccess catalog item is specified by the `myaccess_management_id` argument
        provided to this class and defaults to "a3fdc9781b0621d01c87ed79b04bcb07" if not set.

        To manually view all of the tickets created by this function, you can view them on the
        POE Service account's `sys_user` record. Scroll down to the "Requested Items" tab:
        https://pearsonnow.service-now.com/sys_user.do?sys_id=04def18a97023514e450fffe2153af2d&sysparm_view=ess

        If changes need to be made to this function, you can view all of the `variables` here:
        https://pearsonnow.service-now.com/sc_cat_item.do?sys_id=a3fdc9781b0621d01c87ed79b04bcb07
        The request is broken down into into sections called "Variable sets". E.x. to view all of
        the fields related to creating a new role in an existing category, click the
        "Create a new Role within an existing Category" variable set. The "Variables" tab will show
        all of the field names used by the API in the "Name" column, and which feild they correspond
        to in the myAccess UI in the "Question" column.

        Parameters
        ----------
        role_name : str
            The name of the new role to create.
        application_name : str
            The name of the application in which to create the role. Example: "New Relic".
        application_category : str
            The category within the application for the new role. Example: "Users"
        access_require_manager_approval : bool
            Whether access to the role requires manager approval.
        removal_require_manager_approval : bool
            Whether removal from the role requires manager approval.
        role_description : str
            Description of the role.
        role_justification : str
            Justification users must meet to request access to the role.
        approval_group_names : list of str, optional
            List of 0-2 approval group names for the role.
        request_for_self : bool, optional
            Whether the request is being made on behalf of the current user, or the POE service
            account. Affects the `u_requested_by` and `requested_for` fields. The `opened_by` field
            is always set to the POE service account. Default is False.

        Returns
        -------
        dict
            The result of the ServiceNow request to create the role.

            {
                'number': 'REQ0394325',
                'parent_id': None,
                'parent_table': 'task',
                'request_id': 'dcc63693fb4b6a104e1df46daeefdcbb',
                'request_number': 'REQ0394325',
                'sys_id': 'dcc63693fb4b6a104e1df46daeefdcbb',
                'table': 'sc_request'
            }

        Raises
        ------
        AssertionError
            If the value provided to `approval_group_names` is not a list, the number of items
            in the list is greater than 2, or the resolution of approval group names to ids fails.
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        """
        self._logger.info(
            "Creating role: %s for application: %s, category: %s",
            role_name,
            application_name,
            application_category,
        )

        requester = (
            self.get_user_by_email(get_user_email_from_aws())["sys_id"]
            if request_for_self
            else self.sys_id
        )
        application: dict = self.get_application_by_name(application_name)
        category: dict = self.get_application_category_by_name(
            application["sys_id"], application_category
        )
        approval_group_names = approval_group_names if approval_group_names else []
        assert isinstance(approval_group_names, list), "approval_group_names must be of type: list"
        assert len(approval_group_names) < 3, "Cannot have more than two approval groups"
        approval_groups: list[dict] = [
            self.get_approval_group_by_name(grp) for grp in approval_group_names
        ]
        assert len(approval_groups) == len(approval_group_names), (
            f"Number of approval groups found in SNOW ({len(approval_groups)}) does not match "
            f"the number of approval group names provided ({len(approval_group_names)})"
        )

        additional_params = {}
        if not request_for_self:
            additional_params["approving_manager"] = self.sys_id
        num_approvers: Literal["", "One", "Two"] = ""
        if len(approval_groups) == 1:
            num_approvers = "One"
            additional_params["App_Group1"] = approval_groups[0]["sys_id"]
        elif len(approval_groups) == 2:
            num_approvers = "Two"
            additional_params["App_Group2"] = approval_groups[1]["sys_id"]

        payload = {
            "sysparm_quantity": "1",
            "sysparm_action": "order",
            "sysparm_id": self.myaccess_management_id,
            "variables": {
                "requested_for": requester,
                "u_requested_by": requester,
                "manage_task": "New Application Role",
                "myaccess_management": "true",
                "new_role": "true",
                "Application_name_new": application["sys_id"],
                "Application_Category_new": category["sys_id"],
                "new_app_categoryname": application_category,
                "Enter_new_Role_Name": role_name,
                "mgr_approval": json.dumps(access_require_manager_approval),
                "mgr_removal_approval": json.dumps(removal_require_manager_approval),
                "primary_or_secondaryApproval": num_approvers,
                "Role_Auth": "Use an Active Directory group",
                "request_newAD": "Yes",
                "role_desc": role_description,
                "role_justification": role_justification,
                "is_awsRole": "No",
            },
        }
        payload["variables"].update(additional_params)

        result: dict = self._make_request(
            "post",
            f"{self.base_url}/sn_sc/servicecatalog/items/{self.myaccess_management_id}/order_now",
            json_body=payload,
        ).json()["result"]

        self._logger.info("Submitted ticket# %s to create role %s", result["number"], role_name)
        return result

    @log_it
    def edit_role(
        self,
        role_name: str,
        application_name: Optional[str] = None,
        application_category: Optional[str] = None,
        new_role_name: Optional[str] = None,
        access_require_manager_approval: Optional[bool] = None,
        removal_require_manager_approval: Optional[bool] = None,
        approval_group_names: Optional[list[str]] = None,
        role_description: Optional[str] = None,
        role_justification: Optional[str] = None,
        request_for_self: Optional[bool] = False,
    ) -> dict:
        """
        Edit an existing myAccess role by ordering the myAccess catalog item and setting the
        `manage_task` field to "Edit Role". The ID of the myAccess catalog item is specified by the
        `myaccess_management_id` argument provided to this class and defaults to
        "a3fdc9781b0621d01c87ed79b04bcb07" if not set.

        Parameters
        ----------
        role_name : str
            The name of the existing role to edit.
        application_name : str, optional
            The name of the application containing the role. Used to help identify the specific
            role if there are multiple roles with the same name across different applications.
        application_category : str, optional
            The category within the application for the role. Used to help identify the specific
            role if there are multiple roles with the same name across different categories.
        new_role_name : str, optional
            The new name for the role. If not provided, the role name will remain unchanged.
        access_require_manager_approval : bool, optional
            Whether access to the role requires manager approval. If not provided, the current
            setting will be preserved.
        removal_require_manager_approval : bool, optional
            Whether removal from the role requires manager approval. If not provided, the current
            setting will be preserved.
        approval_group_names : list of str, optional
            List of 0-2 approval group names for the role. If not provided, existing approval
            groups will be preserved.
        role_description : str, optional
            New description of the role. If not provided, the current description will be preserved.
        role_justification : str, optional
            New justification users must meet to request access to the role. If not provided,
            the current justification will be preserved.
        request_for_self : bool, optional
            Whether the request is being made on behalf of the current user, or the POE service
            account. Affects the `u_requested_by` `requested_for` fields. The `opened_by` field is
            always set to the POE service account. Default is False.

        Returns
        -------
        dict
            The result of the myAccess request to edit the role.

            {
                'number': 'REQ0394326',
                'parent_id': None,
                'parent_table': 'task',
                'request_id': 'ecc63693fb4b6a104e1df46daeefdcbb',
                'request_number': 'REQ0394326',
                'sys_id': 'ecc63693fb4b6a104e1df46daeefdcbb',
                'table': 'sc_request'
            }

        Raises
        ------
        AssertionError
            If the value provided to `approval_group_names` is not a list, the number of approval
            groups in the list is greater than 2, the resolution of approval group names to ids
            fails, or the specified role cannot be found.
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        """
        self._logger.info(
            "Updating role: %s for application: %s, category: %s",
            role_name,
            application_name,
            application_category,
        )

        requester = (
            self.get_user_by_email(get_user_email_from_aws())["sys_id"]
            if request_for_self
            else self.sys_id
        )
        role: dict = self.get_role_by_name(role_name, application_name, application_category)
        approval_group_names = approval_group_names if approval_group_names else []
        assert isinstance(approval_group_names, list), "approval_group_names must be of type: list"
        assert len(approval_group_names) < 3, "Cannot have more than two approval groups"
        approval_groups: list[dict] = [
            self.get_approval_group_by_name(grp) for grp in approval_group_names
        ]
        assert len(approval_groups) == len(approval_group_names), (
            f"Number of approval groups found in SNOW ({len(approval_groups)}) does not match "
            f"the number of approval group names provided ({len(approval_group_names)})"
        )

        additional_params = {}
        if not request_for_self:
            additional_params["approving_manager"] = self.sys_id
        if len(approval_groups) == 1:
            additional_params["edit_primary_secondary_apprvl"] = "One"
            additional_params["edit_newAppGrp1"] = approval_groups[0]["sys_id"]
            additional_params["edit_newAppGroup1"] = approval_groups[0]["sys_id"]
        elif len(approval_groups) == 2:
            additional_params["edit_primary_secondary_apprvl"] = "Two"
            additional_params["edit_newAppGrp2"] = approval_groups[1]["sys_id"]

        payload = {
            "sysparm_quantity": "1",
            "sysparm_action": "order",
            "sysparm_id": self.myaccess_management_id,
            "variables": {
                "requested_for": requester,
                "u_requested_by": requester,
                "new_app_categoryname": "asdf",  # required, but not used
                "manage_task": "Edit Role",
                "edit_role": role["sys_id"],
                "edit_newRole": new_role_name or role_name,
                "edit_mgr_approval": json.dumps(
                    access_require_manager_approval
                    if access_require_manager_approval is not None
                    else role["u_requires_manager_approval"]
                ),
                "edit_mgr_removal_approval": json.dumps(
                    removal_require_manager_approval
                    if removal_require_manager_approval is not None
                    else role["u_requires_manager_approval_for_removal_of_access"]
                ),
                "edit_newIsRole": "Use an Active Directory group",
                "edit_request_newAD": "No",
                "edit_newAD": role["u_ad_group_name"],
                "edit_newRoleJust": role_justification or role["u_role_justification"],
                "edit_newRoleDesc": role_description or role["u_role_description"],
            },
        }
        payload["variables"].update(additional_params)

        result: dict = self._make_request(
            "post",
            f"{self.base_url}/sn_sc/servicecatalog/items/{self.myaccess_management_id}/order_now",
            json_body=payload,
        ).json()["result"]

        self._logger.info("Submitted ticket# %s to update role %s", result["number"], role_name)
        return result

    @log_it
    def retire_role(
        self,
        role_name: str,
        application_name: str,
        application_category: str,
        request_for_self: Optional[bool] = False,
    ) -> dict:
        """
        Retire an existing role by ordering the myAccess catalog item and setting the `manage_task`
        field to "Retire Role". The ID of the myAccess catalog item is specified by the
        `myaccess_management_id` argument provided to this class and defaults to
        "a3fdc9781b0621d01c87ed79b04bcb07" if not set.

        Parameters
        ----------
        role_name : str
            The name of the role to retire.
        application_name : str
            The name of the application containing the role.
        application_category : str
            The category within the application for the role.
        request_for_self : bool, optional
            Whether the request is being made on behalf of the current user, or the POE service
            account. Affects the `u_requested_by` and `requested_for` fields. The `opened_by`
            field is always set to the POE service account. Default is False.

        Returns
        -------
        dict
            The result of the service catalog request to retire the role.

            {
                'number': 'REQ0329474',
                'parent_id': None,
                'parent_table': 'task',
                'request_id': 'dcc63693fb4b6a104e1df46daeefdcbb',
                'request_number': 'REQ0329474',
                'sys_id': '3030bc54fb9d6e10a0cef3baaeefdc4c',
                'table': 'sc_request'
            }

        Raises
        ------
        AssertionError
            If the role, application, or category cannot be found.
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        """
        self._logger.info(
            "Retiring role %s / %s / %s", role_name, application_category, application_name
        )

        requester = (
            self.get_user_by_email(get_user_email_from_aws())["sys_id"]
            if request_for_self
            else self.sys_id
        )
        role: dict = self.get_role_by_name(role_name, application_name, application_category)
        application: dict = self.get_application_by_name(application_name)
        category: dict = self.get_application_category_by_name(
            application["sys_id"], application_category
        )

        payload = {
            "sysparm_quantity": "1",
            "sysparm_action": "order",
            "sysparm_id": self.myaccess_management_id,
            "variables": {
                "requested_for": requester,
                "u_requested_by": requester,
                "manage_task": "Retire Role",
                "Application__Servicename": application["sys_id"],
                "AppCategory_name": category["sys_id"],
                "role_to_retire": role["sys_id"],
                "new_app_categoryname": application_category,
            },
        }
        if not request_for_self:
            payload["variables"]["approving_manager"] = self.sys_id

        result: dict = self._make_request(
            "post",
            f"{self.base_url}/sn_sc/servicecatalog/items/{self.myaccess_management_id}/order_now",
            json_body=payload,
        ).json()["result"]

        self._logger.info("Submitted ticket# %s to retire role %s", result["number"], role_name)
        return result

    @log_it
    def get_approval_record_for_req(
        self, req_number: str, request_for_self: Optional[bool] = False
    ) -> dict:
        """
        Retrieve an approval record for a ServiceNow request item (RITM) by request number (REQ).

        This function searches for approval records in the sysapproval_approver table that are
        associated with the specified request number. It then looks for an approval record
        where the current user (or POE service account) is listed as the approver.

        Parameters
        ----------
        req_number : str
            The ServiceNow request number (e.g., "REQ0394325") to search for approval records.
        request_for_self : bool, optional
            Whether to search for approval records where the current user is the approver (True)
            or where the POE service account is the approver (False). Default is False.

        Returns
        -------
        dict
            The approval record where the requester is listed as the approver. Returns an empty
            dictionary if no matching approval record is found.

            {
                'approval_column': 'approval',
                'approval_journal_column': 'approval_history',
                'approval_reason': '',
                'approval_source': 'ui.desktop',
                'approver': { # The user who is approving the request (Leah in this case)
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sys_user/41d3a8d09703d51091e1fffe2153afe6',
                    'value': '41d3a8d09703d51091e1fffe2153afe6'
                },
                'comments': '',
                'document_id': { # The RITM this approval is for
                    'link': 'https://pearsonnow.service-now.com/api/now/table/sc_req_item/57c63a9b8303ae50752999226daad346',
                    'value': '57c63a9b8303ae50752999226daad346'
                },
                'due_date': '2025-08-04 15:37:18',
                'expected_start': '2025-08-04 15:37:18',
                'group': '',
                'iteration': '1',
                'order': '',
                'source_table': 'sc_req_item',
                'state': 'approved', # One of not requested, requested, approved, rejected, cancelled, not_required
                'sys_created_by': 'system',
                'sys_created_on': '2025-08-04 15:37:18',
                'sys_id': '3fc6ba9b8303ae50752999226daad34e',
                'sys_mod_count': '1',
                'sys_tags': '',
                'sys_updated_by': 'UCRONLE',
                'sys_updated_on': '2025-08-04 16:23:56',
                'sysapproval': {
                    'link': 'https://pearsonnow.service-now.com/api/now/table/task/57c63a9b8303ae50752999226daad346',
                    'value': '57c63a9b8303ae50752999226daad346'
                }
            }

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails.
        """
        self._logger.info("Getting approval record for: %s", req_number)

        requester_sys_id: str = (
            self.get_user_by_email(get_user_email_from_aws())["sys_id"]
            if request_for_self
            else self.sys_id
        )

        result: list[dict] = self._make_request(
            "get",
            f"{self.base_url}/now/table/sysapproval_approver",
            params={"sysparm_query": f"sysapproval.ref_sc_req_item.request.number={req_number}"},
        ).json()["result"]

        self._logger.info("Found %s approvers for REQ number %s", len(result), req_number)
        self._logger.info("Looking for a valid approver...")

        for approval in result:
            if approval["approver"]["value"] == requester_sys_id:
                return approval
        return {}

    @log_it
    def approve_request(self, approval_record_sys_id: str) -> dict:
        """
        Approve a ServiceNow approval record by updating its state to 'approved'.

        This method updates an approval record in the sysapproval_approver table by setting the
        state field to "approved". This is typically used to programmatically approve myAccess
        requests.

        Parameters
        ----------
        approval_record_sys_id : str
            The sys_id of the approval record to approve. This should be obtained from
            a previous call to `get_approval_record_for_req()`.

        Returns
        -------
        dict
            The updated approval record from ServiceNow after setting the state to approved.

            TODO: Example response structure

        Raises
        ------
        requests.HTTPError
            If the HTTP request to ServiceNow fails or if the approval record doesn't exist.
        """
        self._logger.info("Approving approval record %s", approval_record_sys_id)

        response: requests.Response = self._make_request(
            "put",
            f"{self.base_url}/now/table/sysapproval_approver/{approval_record_sys_id}",
            json_body={"state": "approved"},
        )

        return response.json()

    @log_it
    def _make_request(
        self,
        method: str,
        url: str,
        params: Optional[dict[str, Any]] = None,
        additional_headers: Optional[dict[str, Any]] = None,
        json_body: Optional[Any] = None,
    ) -> requests.Response:
        """
        Wrapper for `requests.request()` using the configured instance session with error handling.

        This is a private method that centralizes HTTP request logic for all ServiceNow
        API calls. It handles authentication, error logging, and response validation.

        Parameters
        ----------
        method : str
            The HTTP method to use for the request. This parameter is case insensitive.
            Must be one of: 'delete', 'get', 'head', 'patch', 'post', 'put'.
        url : str
            The complete URL for the HTTP request.
        params : dict of str to Any, optional
            Query parameters to include in the request URL. Default is None.
        additional_headers : dict of str to Any, optional
            Additional HTTP headers to include in the request. Default is None.
        json_body : Any, optional
            JSON serializable object to include as the request body. Passed to the `json` parameter
            of the `requests.request()` method. Default is None.

        Returns
        -------
        requests.Response
            The HTTP response object from the ServiceNow API.

        Raises
        ------
        AssertionError
            If the provided HTTP method is not valid.
        requests.HTTPError
            If the HTTP request fails (status code >= 400). Error details are logged
            before re-raising the exception.

        Notes
        -----
        This method automatically:
        - Uses the configured session authentication
        - Logs detailed error information on HTTP failures
        - Validates the HTTP method before making the request

        Examples
        --------
        >>> response = self._make_request('GET', 'https://api.servicenow.com/table/user')
        >>> response = self._make_request(
        ...     'POST',
        ...     'https://api.servicenow.com/table/user',
        ...     json_body={'name': 'John Doe'}
        ... )
        """
        response: requests.Response = self.http_session.request(
            method,
            url,
            params=params,
            headers=additional_headers,
            json=json_body,
        )
        try:
            response.raise_for_status()
        except requests.HTTPError as ex:
            self._logger.exception(
                "Error making request. URL: %s, Status code: %s, reason: %s, response body: %s",
                url,
                response.status_code,
                response.reason,
                response.text,
            )
            raise ex
        return response

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}()"
