@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_change_request_list_task_from_config(page: Page):
    expected_goal = 'Create a filter for the list to extract all entries where "Assignment group" is "Hardware" and "Assigned to" is "Bow Ruggeri".'
    generic_task_cheat_test(
        FilterChangeRequestListTask,
        FILTER_CHANGE_REQUEST_LIST_CONFIG_PATH,
        page,
        expected_goal=expected_goal,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterHardwareListTask, FILTER_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterIncidentListTask, FILTER_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        FilterServiceCatalogItemListTask,
        FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH,
        page,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")


        FILTER_CHANGE_REQUEST_LIST_CONFIG_PATH,
        page,
        expected_goal=expected_goal,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterHardwareListTask, FILTER_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterIncidentListTask, FILTER_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        FilterServiceCatalogItemListTask,
        FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH,
        page,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)


@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterIncidentListTask, FILTER_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        FilterServiceCatalogItemListTask,
        FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH,
        page,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)


@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        FilterServiceCatalogItemListTask,
        FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH,
        page,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)


        FilterServiceCatalogItemListTask,
        FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH,
        page,
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        SortServiceCatalogItemListTask, SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH, page
    )


@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_filter_user_list_task_from_config(page: Page):
    generic_task_cheat_test(FilterUserListTask, FILTER_USER_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        SortServiceCatalogItemListTask, SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH, page
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_user_list_task_from_config(page: Page):
    expected_goal = 'Sort the "users" list by the following fields:\n - Active (descending)'


@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_asset_list_task_from_config(page: Page):
    generic_task_cheat_test(SortAssetListTask, SORT_ASSET_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        SortServiceCatalogItemListTask, SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH, page
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_user_list_task_from_config(page: Page):
    expected_goal = 'Sort the "users" list by the following fields:\n - Active (descending)'
    generic_task_cheat_test(
        SortUserListTask, SORT_USER_LIST_CONFIG_PATH, page, expected_goal=expected_goal
    )


@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_change_request_list_task_from_config(page: Page):
    generic_task_cheat_test(SortChangeRequestListTask, SORT_CHANGE_REQUEST_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        SortServiceCatalogItemListTask, SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH, page
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_user_list_task_from_config(page: Page):
    expected_goal = 'Sort the "users" list by the following fields:\n - Active (descending)'
    generic_task_cheat_test(
        SortUserListTask, SORT_USER_LIST_CONFIG_PATH, page, expected_goal=expected_goal
    )


def test_sort_hardware_list_task_from_config(page: Page):
    generic_task_cheat_test(SortHardwareListTask, SORT_HARDWARE_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_incident_list_task_from_config(page: Page):
    generic_task_cheat_test(SortIncidentListTask, SORT_INCIDENT_LIST_CONFIG_PATH, page)

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_service_catalog_item_list_task_from_config(page: Page):
    generic_task_cheat_test(
        SortServiceCatalogItemListTask, SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH, page
    )

@pytest.mark.slow
@pytest.mark.skip(reason="Tests are too slow")
def test_sort_user_list_task_from_config(page: Page):
    expected_goal = 'Sort the "users" list by the following fields:\n - Active (descending)'
    generic_task_cheat_test(
        SortUserListTask, SORT_USER_LIST_CONFIG_PATH, page, expected_goal=expected_goal
    )


import pytest
from playwright.sync_api import Page
from browsergym.workarena.tasks.list import FilterIncidentListTask

# These are all the same ways to express the same filter query (empty string) in ServiceNow.
@pytest.mark.parametrize(
    "query",
    [
        "assigned_toEMPTYSTRING^short_descriptionISEMPTY^description=This is a beautiful incident",
        "assigned_toISEMPTY^short_descriptionEMPTYSTRING^description=This is a beautiful incident^",
        "assigned_toISEMPTY^short_descriptionISEMPTY^description=This is a beautiful incident",
        "assigned_toEMPTYSTRING^short_descriptionEMPTYSTRING^description=This is a beautiful incident^",
        "assigned_to=^short_description=^description=This is a beautiful incident",
    ],
)
@pytest.mark.slow
def test_validate_filter_list_task(page: Page, query):
    fixed_config = {
        "filter_columns": [
            "short_description",
            "assigned_to",
            "description",
        ],
        "filter_kind": "AND",
        "filter_values": [
            "",
            "",
            "This is a beautiful incident",
        ],
    }
    task = FilterIncidentListTask(seed=1, fixed_config=fixed_config)
    _, _ = task.setup(page=page)
    query = query.replace("^", r"%5E").replace("=", r"%3D")
    task.page.goto(
        task.instance.snow_url
        + rf"/now/nav/ui/classic/params/target/incident_list.do?sysparm_query={query}"
    )
    reward, done, _, info = task.validate(page, [])
    task.teardown()
    assert done is True and reward == 1.0 and "Correct filter" in info["message"]

# Different ways in which the filter is wrong
@pytest.mark.parametrize(
    "query, expected_message",
    [
        ("", "There are no filters yet"),
        ("assignment_groupEMPTYSTRING", "Incorrect number of filter conditions"),
        (
            "assigned_toEMPTYSTRING^short_description!=Description",
            "Unexpected operator in filter condition",
        ),
        ("assigned_toEMPTYSTRING^short_description=Description", "Incorrect filter columns"),
        ("assigned_toISEMPTY^description=My Description", "Incorrect filter values"),
    ],
)
@pytest.mark.slow
def test_invalid_filter_list_task(page: Page, query, expected_message):
    fixed_config = {
        "filter_columns": [
            "assigned_to",
            "description",
        ],
        "filter_kind": "AND",
        "filter_values": [
            "",
            "Description",
        ],
    }
    task = FilterIncidentListTask(seed=1, fixed_config=fixed_config)
    _, _ = task.setup(page=page)
    query = query.replace("^", r"%5E").replace("=", r"%3D")
    task.page.goto(
        task.instance.snow_url
        + f"/now/nav/ui/classic/params/target/incident_list.do?sysparm_query={query}"
    )
    reward, done, _, info = task.validate(page, [])
    task.teardown()
    print(info["message"])
    assert done is False and reward == 0.0 and expected_message in info["message"]


import pytest
from playwright.sync_api import Page
from browsergym.workarena.tasks.list import FilterIncidentListTask

# These are all the same ways to express the same filter query (empty string) in ServiceNow.
@pytest.mark.parametrize(
    "query",
    [
        "assigned_toEMPTYSTRING^short_descriptionISEMPTY^description=This is a beautiful incident",
        "assigned_toISEMPTY^short_descriptionEMPTYSTRING^description=This is a beautiful incident^",
        "assigned_toISEMPTY^short_descriptionISEMPTY^description=This is a beautiful incident",
        "assigned_toEMPTYSTRING^short_descriptionEMPTYSTRING^description=This is a beautiful incident^",
        "assigned_to=^short_description=^description=This is a beautiful incident",
    ],
)
@pytest.mark.slow
def test_validate_filter_list_task(page: Page, query):
    fixed_config = {
        "filter_columns": [
            "short_description",
            "assigned_to",
            "description",
        ],
        "filter_kind": "AND",
        "filter_values": [
            "",
            "",
            "This is a beautiful incident",
        ],
    }
    task = FilterIncidentListTask(seed=1, fixed_config=fixed_config)
    _, _ = task.setup(page=page)
    query = query.replace("^", r"%5E").replace("=", r"%3D")
    task.page.goto(
        task.instance.snow_url
        + rf"/now/nav/ui/classic/params/target/incident_list.do?sysparm_query={query}"
    )
    reward, done, _, info = task.validate(page, [])
    task.teardown()
    assert done is True and reward == 1.0 and "Correct filter" in info["message"]

# Different ways in which the filter is wrong
@pytest.mark.parametrize(
    "query, expected_message",
    [
        ("", "There are no filters yet"),
        ("assignment_groupEMPTYSTRING", "Incorrect number of filter conditions"),
        (
            "assigned_toEMPTYSTRING^short_description!=Description",
            "Unexpected operator in filter condition",
        ),
        ("assigned_toEMPTYSTRING^short_description=Description", "Incorrect filter columns"),
        ("assigned_toISEMPTY^description=My Description", "Incorrect filter values"),
    ],
)
@pytest.mark.slow
def test_invalid_filter_list_task(page: Page, query, expected_message):
    fixed_config = {
        "filter_columns": [


    reward, done, _, info = task.validate(page, [])
    task.teardown()
    assert done is True and reward == 1.0 and "Correct filter" in info["message"]

# Different ways in which the filter is wrong
@pytest.mark.parametrize(
    "query, expected_message",
    [
        ("", "There are no filters yet"),
        ("assignment_groupEMPTYSTRING", "Incorrect number of filter conditions"),
        (
            "assigned_toEMPTYSTRING^short_description!=Description",
            "Unexpected operator in filter condition",
        ),
        ("assigned_toEMPTYSTRING^short_description=Description", "Incorrect filter columns"),
        ("assigned_toISEMPTY^description=My Description", "Incorrect filter values"),
    ],
)
@pytest.mark.slow
def test_invalid_filter_list_task(page: Page, query, expected_message):
    fixed_config = {
        "filter_columns": [
            "assigned_to",
            "description",
        ],
        "filter_kind": "AND",
        "filter_values": [
            "",
            "Description",
        ],
    }
    task = FilterIncidentListTask(seed=1, fixed_config=fixed_config)
    _, _ = task.setup(page=page)
    query = query.replace("^", r"%5E").replace("=", r"%3D")
    task.page.goto(
        task.instance.snow_url
        + f"/now/nav/ui/classic/params/target/incident_list.do?sysparm_query={query}"
    )
    reward, done, _, info = task.validate(page, [])
    task.teardown()
    print(info["message"])
    assert done is False and reward == 0.0 and expected_message in info["message"]


"""
A demonstration of how action traces (with observations) can be extracted
for WorkArena tasks without modifying the task code.

Author: Alexandre Drouin (alexandre.drouin@servicenow.com)

Notes:
- This approach relies on monkey patching the playwright actions to log the actions and observations.
  It has not been tested for parallel execution. It might work with multiprocessing, but it will for
  sure not work with multithreading.

"""

import importlib
import logging
import os
import pickle
import playwright.sync_api as playwright_sync

from browsergym.core.env import BrowserEnv
from browsergym.workarena import ALL_WORKARENA_TASKS
from collections import defaultdict
from tenacity import retry, stop_after_attempt, wait_fixed
from time import time

N_PER_TASK = 10

def monkey_patch_playwright(observation_callback, trace_storage):
    """
    A function that overrides the default playwright actions to log the actions and observations.

    Parameters:
    ------------
    observation_callback: callable
        A function that returns the observation of the environment.
    trace_storage: list
        A list to store the trace of the actions and observations.
        These will be appended in-place.

    """

    def wrapper(func, interface):
        def wrapped(*args, **kwargs):
            # Get the observation
            obs = observation_callback()

            # Get the BID of the element on which we are acting.
            if interface.__name__ == "Locator":
                # Get the locator
                locator = args[0]
                # Get the BID
                bid = locator.element_handle().evaluate('(el) => el.getAttribute("bid")')
            elif interface.__name__ == "Keyboard":
                # Get the BID of the element
                bid = "keyboard"
            else:
                # Get the BID of the element
                bid = args[0].evaluate('(el) => el.getAttribute("bid")')

            logging.info(f"Action: {func.__name__} BID: {bid}  --   Args: {args[1:]} {kwargs}")
            trace_storage.append(
                {
                    "obs": obs,
                    "action": func.__name__,
                    "args": args[1:],
                    "kwargs": kwargs,
                    "bid": bid,
                    "time": time(),
                }
            )

            # Resume action
            return func(*args, **kwargs)

        return wrapped

    # Interfaces and actions we want to monkey patch
    importlib.reload(playwright_sync)
    from playwright.sync_api import Page, Frame, Locator, Keyboard, ElementHandle

    # TODO: Make sure the list of interfaces and actions is exhaustive
    #       It covers all that is used in WorkArena cheats as of April 11, 2024
    interfaces = [Page, Frame, Locator, Keyboard, ElementHandle]
    actions = ["click", "select_option", "set_checked", "fill", "press", "type", "down", "up"]

    for interface in interfaces:
        for action in actions:
            if hasattr(interface, action):
                setattr(interface, action, wrapper(getattr(interface, action), interface))
                print(f"Monkey patched {interface.__name__}.{action}")

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def extract_trace(task_cls, headless=True):
    """
    Extracts the trace of actions and observations for a given task.

    Parameters:
    ------------
    task_cls: class
        The class of the task to extract the trace from.

    """
    # Instantiate a new environment
    env = BrowserEnv(task_entrypoint=task_cls, headless=headless, slow_mo=1000)

    # Setup customized tracing
    trace = []
    monkey_patch_playwright(observation_callback=env._get_obs, trace_storage=trace)

    env.reset()
    env.task.cheat(env.page, env.chat.messages)
    env.close()

    return trace

if __name__ == "__main__":
    os.makedirs("trace_profiling", exist_ok=True)

    task_traces = defaultdict(list)
    for task in ALL_WORKARENA_TASKS:
        print("Task:", task)
        for i in range(N_PER_TASK):
            print(f"Extracting trace {i+1}/{N_PER_TASK}")
            trace = extract_trace(task, headless=True)
            task_traces[task].append(trace)

    pickle.dump(task_traces, open("trace_profiling/task_traces.pkl", "wb"))


  sure not work with multithreading.

"""

import importlib
import logging
import os
import pickle
import playwright.sync_api as playwright_sync

from browsergym.core.env import BrowserEnv
from browsergym.workarena import ALL_WORKARENA_TASKS
from collections import defaultdict
from tenacity import retry, stop_after_attempt, wait_fixed
from time import time

N_PER_TASK = 10

def monkey_patch_playwright(observation_callback, trace_storage):
    """
    A function that overrides the default playwright actions to log the actions and observations.

    Parameters:
    ------------
    observation_callback: callable
        A function that returns the observation of the environment.
    trace_storage: list
        A list to store the trace of the actions and observations.
        These will be appended in-place.

    """

    def wrapper(func, interface):
        def wrapped(*args, **kwargs):
            # Get the observation
            obs = observation_callback()

            # Get the BID of the element on which we are acting.
            if interface.__name__ == "Locator":
                # Get the locator
                locator = args[0]
                # Get the BID
                bid = locator.element_handle().evaluate('(el) => el.getAttribute("bid")')
            elif interface.__name__ == "Keyboard":
                # Get the BID of the element
                bid = "keyboard"
            else:
                # Get the BID of the element
                bid = args[0].evaluate('(el) => el.getAttribute("bid")')

            logging.info(f"Action: {func.__name__} BID: {bid}  --   Args: {args[1:]} {kwargs}")
            trace_storage.append(
                {
                    "obs": obs,
                    "action": func.__name__,
                    "args": args[1:],
                    "kwargs": kwargs,
                    "bid": bid,
                    "time": time(),
                }
            )

            # Resume action
            return func(*args, **kwargs)

        return wrapped

    # Interfaces and actions we want to monkey patch
    importlib.reload(playwright_sync)
    from playwright.sync_api import Page, Frame, Locator, Keyboard, ElementHandle

    # TODO: Make sure the list of interfaces and actions is exhaustive
    #       It covers all that is used in WorkArena cheats as of April 11, 2024
    interfaces = [Page, Frame, Locator, Keyboard, ElementHandle]
    actions = ["click", "select_option", "set_checked", "fill", "press", "type", "down", "up"]

    for interface in interfaces:
        for action in actions:
            if hasattr(interface, action):
                setattr(interface, action, wrapper(getattr(interface, action), interface))
                print(f"Monkey patched {interface.__name__}.{action}")

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def extract_trace(task_cls, headless=True):
    """
    Extracts the trace of actions and observations for a given task.

    Parameters:
    ------------
    task_cls: class
        The class of the task to extract the trace from.

    """
    # Instantiate a new environment
    env = BrowserEnv(task_entrypoint=task_cls, headless=headless, slow_mo=1000)

    # Setup customized tracing
    trace = []
    monkey_patch_playwright(observation_callback=env._get_obs, trace_storage=trace)


        return wrapped

    # Interfaces and actions we want to monkey patch
    importlib.reload(playwright_sync)
    from playwright.sync_api import Page, Frame, Locator, Keyboard, ElementHandle

    # TODO: Make sure the list of interfaces and actions is exhaustive
    #       It covers all that is used in WorkArena cheats as of April 11, 2024
    interfaces = [Page, Frame, Locator, Keyboard, ElementHandle]
    actions = ["click", "select_option", "set_checked", "fill", "press", "type", "down", "up"]

    for interface in interfaces:
        for action in actions:
            if hasattr(interface, action):
                setattr(interface, action, wrapper(getattr(interface, action), interface))
                print(f"Monkey patched {interface.__name__}.{action}")

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def extract_trace(task_cls, headless=True):
    """
    Extracts the trace of actions and observations for a given task.

    Parameters:
    ------------
    task_cls: class
        The class of the task to extract the trace from.

    """
    # Instantiate a new environment
    env = BrowserEnv(task_entrypoint=task_cls, headless=headless, slow_mo=1000)

    # Setup customized tracing
    trace = []
    monkey_patch_playwright(observation_callback=env._get_obs, trace_storage=trace)

    env.reset()
    env.task.cheat(env.page, env.chat.messages)
    env.close()

    return trace

if __name__ == "__main__":
    os.makedirs("trace_profiling", exist_ok=True)

    task_traces = defaultdict(list)
    for task in ALL_WORKARENA_TASKS:
        print("Task:", task)
        for i in range(N_PER_TASK):
            print(f"Extracting trace {i+1}/{N_PER_TASK}")
            trace = extract_trace(task, headless=True)
            task_traces[task].append(trace)

    pickle.dump(task_traces, open("trace_profiling/task_traces.pkl", "wb"))


wrapper

from time import time

N_PER_TASK = 10

def monkey_patch_playwright(observation_callback, trace_storage):
    """
    A function that overrides the default playwright actions to log the actions and observations.

    Parameters:
    ------------
    observation_callback: callable
        A function that returns the observation of the environment.
    trace_storage: list
        A list to store the trace of the actions and observations.
        These will be appended in-place.

    """

    def wrapper(func, interface):
        def wrapped(*args, **kwargs):
            # Get the observation
            obs = observation_callback()

            # Get the BID of the element on which we are acting.
            if interface.__name__ == "Locator":
                # Get the locator
                locator = args[0]
                # Get the BID
                bid = locator.element_handle().evaluate('(el) => el.getAttribute("bid")')
            elif interface.__name__ == "Keyboard":
                # Get the BID of the element
                bid = "keyboard"
            else:
                # Get the BID of the element
                bid = args[0].evaluate('(el) => el.getAttribute("bid")')

            logging.info(f"Action: {func.__name__} BID: {bid}  --   Args: {args[1:]} {kwargs}")
            trace_storage.append(
                {
                    "obs": obs,
                    "action": func.__name__,
                    "args": args[1:],
                    "kwargs": kwargs,
                    "bid": bid,
                    "time": time(),
                }
            )

            # Resume action
            return func(*args, **kwargs)

        return wrapped

    # Interfaces and actions we want to monkey patch
    importlib.reload(playwright_sync)
    from playwright.sync_api import Page, Frame, Locator, Keyboard, ElementHandle

    # TODO: Make sure the list of interfaces and actions is exhaustive
    #       It covers all that is used in WorkArena cheats as of April 11, 2024
    interfaces = [Page, Frame, Locator, Keyboard, ElementHandle]
    actions = ["click", "select_option", "set_checked", "fill", "press", "type", "down", "up"]

    for interface in interfaces:
        for action in actions:
            if hasattr(interface, action):
                setattr(interface, action, wrapper(getattr(interface, action), interface))
                print(f"Monkey patched {interface.__name__}.{action}")

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def extract_trace(task_cls, headless=True):
    """


wrapped

N_PER_TASK = 10

def monkey_patch_playwright(observation_callback, trace_storage):
    """
    A function that overrides the default playwright actions to log the actions and observations.

    Parameters:
    ------------
    observation_callback: callable
        A function that returns the observation of the environment.
    trace_storage: list
        A list to store the trace of the actions and observations.
        These will be appended in-place.

    """

    def wrapper(func, interface):
        def wrapped(*args, **kwargs):
            # Get the observation
            obs = observation_callback()

            # Get the BID of the element on which we are acting.
            if interface.__name__ == "Locator":
                # Get the locator
                locator = args[0]
                # Get the BID
                bid = locator.element_handle().evaluate('(el) => el.getAttribute("bid")')
            elif interface.__name__ == "Keyboard":
                # Get the BID of the element
                bid = "keyboard"
            else:
                # Get the BID of the element
                bid = args[0].evaluate('(el) => el.getAttribute("bid")')

            logging.info(f"Action: {func.__name__} BID: {bid}  --   Args: {args[1:]} {kwargs}")
            trace_storage.append(
                {
                    "obs": obs,
                    "action": func.__name__,
                    "args": args[1:],
                    "kwargs": kwargs,
                    "bid": bid,
                    "time": time(),
                }
            )

            # Resume action
            return func(*args, **kwargs)

        return wrapped

    # Interfaces and actions we want to monkey patch
    importlib.reload(playwright_sync)
    from playwright.sync_api import Page, Frame, Locator, Keyboard, ElementHandle

    # TODO: Make sure the list of interfaces and actions is exhaustive
    #       It covers all that is used in WorkArena cheats as of April 11, 2024
    interfaces = [Page, Frame, Locator, Keyboard, ElementHandle]
    actions = ["click", "select_option", "set_checked", "fill", "press", "type", "down", "up"]

    for interface in interfaces:
        for action in actions:
            if hasattr(interface, action):
                setattr(interface, action, wrapper(getattr(interface, action), interface))
                print(f"Monkey patched {interface.__name__}.{action}")

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))


from browsergym.core.env import BrowserEnv
from browsergym.workarena import get_all_tasks_agents

AGENT_L2_SAMPLED_SET = get_all_tasks_agents(filter="l2")

AGENT_L2_SAMPLED_TASKS, AGENT_L2_SEEDS = [sampled_set[0] for sampled_set in AGENT_L2_SAMPLED_SET], [
    sampled_set[1] for sampled_set in AGENT_L2_SAMPLED_SET
]
from time import sleep

for task, seed in zip(AGENT_L2_SAMPLED_TASKS, AGENT_L2_SEEDS):
    print("Task:", task)

    # Instantiate a new environment
    env = BrowserEnv(task_entrypoint=task, headless=False, slow_mo=1000)
    env.reset()

    # Cheat functions use Playwright to automatically solve the task
    env.chat.add_message(role="assistant", msg="On it. Please wait...")

    for i in range(len(env.task)):
        sleep(1)
        env.task.cheat(page=env.page, chat_messages=env.chat.messages, subtask_idx=i)
        sleep(1)
        reward, done, message, info = env.task.validate(
            page=env.page, chat_messages=env.chat.messages
        )

    if reward == 1:
        env.chat.add_message(role="user", msg="Yes, that works. Thanks!")
    else:
        env.chat.add_message(
            role="user", msg=f"No, that doesn't work. {message.get('message', '')}"
        )

    sleep(3)
    env.close()


src.browsergym.workarena.config

from importlib import resources
from json import load as json_load
from os.path import exists

from ..workarena import data_files
from ..workarena.tasks import utils

# ServiceNow configuration
SNOW_DATA_LOOKBACK_MINUTES = 5
SNOW_BROWSER_TIMEOUT = 30000  # Milliseconds
SNOW_JS_UTILS_FILEPATH = str(resources.files(utils).joinpath("js_utils.js"))
SNOW_SUPPORTED_RELEASES = ["washingtondc"]

# Path to the Menu navigation task configuration
ALL_MENU_PATH = str(resources.files(data_files).joinpath("task_configs/all_menu.json"))

# Path to the dashboard/report retrieval task configurations
DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/dashboard_retrieval_minmax_task.json")
)
DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/dashboard_retrieval_value_task.json")
)
REPORT_RETRIEVAL_MINMAX_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/report_retrieval_minmax_task.json")
)
REPORT_RETRIEVAL_VALUE_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/report_retrieval_value_task.json")
)

# Path to knowledge base task configurations
KB_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/knowledge_base_configs.json")
)
# Path to the Impersonation task configuration
IMPERSONATION_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/impersonation_users.json")
)
# Path to the service catalog configs
ORDER_DEVELOPER_LAPTOP_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_developer_laptop_task.json")
)
ORDER_IPAD_MINI_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_ipad_mini_task.json")
)
ORDER_IPAD_PRO_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_ipad_pro_task.json")
)
ORDER_SALES_LAPTOP_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_sales_laptop_task.json")
)
ORDER_STANDARD_LAPTOP_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_standard_laptop_task.json")
)
ORDER_APPLE_WATCH_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_apple_watch_task.json")
)
ORDER_APPLE_MAC_BOOK_PRO15_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_apple_mac_book_pro15_task.json")
)
ORDER_DEVELOPMENT_LAPTOP_PC_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_development_laptop_pc_task.json")
)
ORDER_LOANER_LAPTOP_TASK_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/order_loaner_laptop_task.json")
)

# Knowledge base that is included with the benchmark
KB_NAME = "General Knowledge"
KB_FILEPATH = str(resources.files(data_files).joinpath("setup_files/knowledge/knowledge_base.json"))
PROTOCOL_KB_NAME = "Company Protocols"
PROTOCOL_KB_FILEPATH = str(
    resources.files(data_files).joinpath("setup_files/knowledge/protocols.json")
)

# Form tasks
CREATE_CHANGE_REQUEST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/create_change_request_task.json")
)
CREATE_HARDWARE_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/create_hardware_asset_task.json")
)
CREATE_INCIDENT_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/create_incident_task.json")
)
CREATE_PROBLEM_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/create_problem_task.json")
)
CREATE_USER_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/create_user_task.json")
)
# List tasks
FILTER_ASSET_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_asset_list_task.json")
)
FILTER_CHANGE_REQUEST_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_change_request_list_task.json")
)
FILTER_HARDWARE_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_hardware_list_task.json")
)
FILTER_INCIDENT_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_incident_list_task.json")
)
FILTER_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_service_catalog_item_list_task.json")
)
FILTER_USER_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/filter_user_list_task.json")
)
SORT_ASSET_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_asset_list_task.json")
)
SORT_CHANGE_REQUEST_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_change_request_list_task.json")
)
SORT_HARDWARE_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_hardware_list_task.json")
)
SORT_INCIDENT_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_incident_list_task.json")
)
SORT_SERVICE_CATALOG_ITEM_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_service_catalog_item_list_task.json")
)
SORT_USER_LIST_CONFIG_PATH = str(
    resources.files(data_files).joinpath("task_configs/sort_user_list_task.json")
)

# Custom workflows that are included with the benchmark
WORKFLOWS = {
    "kb_publish": {
        "name": "WorkArena Auto-Publish",
        "update_set": str(
            resources.files(data_files).joinpath(
                "setup_files/knowledge/kb_autopublish_workflow.xml"
            )
        ),
    }
}

# Custom UI Themes
UI_THEMES_UPDATE_SET = {
    "name": "WorkArena UI Themes",
    "update_set": str(
        resources.files(data_files).joinpath("setup_files/ui_themes/workarena_themes.xml")
    ),
    "variants": [
        "Astranova",
        "Charlies",
        "Great pasta",
        "Mighty capital",
        "Speedy tires",
        "Skyward",
        "Turbobots",
        "Ultrashoes",
        "Vitasphere",
        "Workarena",
    ],
}

# Expected columns for list tasks; used in setup
EXPECTED_ASSET_LIST_COLUMNS_PATH = str(
    resources.files(data_files).joinpath("setup_files/lists/expected_asset_list_columns.json")
)
EXPECTED_CHANGE_REQUEST_COLUMNS_PATH = str(
    resources.files(data_files).joinpath(
        "setup_files/lists/expected_change_request_list_columns.json"
    )
)
EXPECTED_EXPENSE_LINE_COLUMNS_PATH = str(
    resources.files(data_files).joinpath(
        "setup_files/lists/expected_expense_line_list_columns.json"
    )
)
EXPECTED_HARDWARE_COLUMNS_PATH = str(
    resources.files(data_files).joinpath("setup_files/lists/expected_hardware_list_columns.json")
)
EXPECTED_INCIDENT_COLUMNS_PATH = str(
    resources.files(data_files).joinpath("setup_files/lists/expected_incident_list_columns.json")
)
EXPECTED_PROBLEM_COLUMNS_PATH = str(
    resources.files(data_files).joinpath("setup_files/lists/expected_problem_list_columns.json")
)
EXPECTED_REQUESTED_ITEMS_COLUMNS_PATH = str(
    resources.files(data_files).joinpath(
        "setup_files/lists/expected_requested_items_list_columns.json"
    )
)
EXPECTED_SERVICE_CATALOG_COLUMNS_PATH = str(
    resources.files(data_files).joinpath(
        "setup_files/lists/expected_service_catalog_list_columns.json"
    )
)
EXPECTED_USER_COLUMNS_PATH = str(
    resources.files(data_files).joinpath("setup_files/lists/expected_user_list_columns.json")
)
# Expected form fields for form tasks; used in setup
EXPECTED_ASSET_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_asset_form_fields.json")
)
EXPECTED_CHANGE_REQUEST_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath(
        "setup_files/forms/expected_change_request_form_fields.json"
    )
)

EXPECTED_HARDWARE_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_hardware_form_fields.json")
)
EXPECTED_INCIDENT_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_incident_form_fields.json")
)
EXPECTED_PROBLEM_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_problem_form_fields.json")
)
EXPECTED_USER_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_user_form_fields.json")
)
EXPECTED_REQUEST_ITEM_FORM_FIELDS_PATH = str(
    resources.files(data_files).joinpath("setup_files/forms/expected_request_item_form_fields.json")
)

# Report date filter patch flag
REPORT_PATCH_FLAG = "WORKARENA_DATE_FILTER_PATCH"
REPORT_FILTER_PROPERTY = "workarena.report.filter.config"


src.browsergym.workarena.instance

import json
import os
import requests
import re

from playwright.sync_api import sync_playwright
from typing import Optional

from .config import SNOW_BROWSER_TIMEOUT, REPORT_FILTER_PROPERTY

class SNowInstance:
    """
    Utility class to access a ServiceNow instance.

    """

    def __init__(
        self,
        snow_url: Optional[str] = None,
        snow_credentials: Optional[tuple[str, str]] = None,
    ) -> None:
        """
        Set up a ServiceNow instance API

        Parameters:
        -----------
        snow_url: str
            The URL of a SNow instance. If None, will try to get the value from the environment variable SNOW_INSTANCE_URL.
        snow_credentials: (str, str)
            The username and password used to access the SNow instance. If None, will try to get the values from the
            environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD.

        """
        # try to get these values from environment variables if not provided
        if snow_url is None:
            if "SNOW_INSTANCE_URL" in os.environ:
                snow_url = os.environ["SNOW_INSTANCE_URL"]
            else:
                raise ValueError(
                    f"Please provide a ServiceNow instance URL (you can use the environment variable SNOW_INSTANCE_URL)"
                )

        if snow_credentials is None:
            if "SNOW_INSTANCE_UNAME" in os.environ and "SNOW_INSTANCE_PWD" in os.environ:
                snow_credentials = (
                    os.environ["SNOW_INSTANCE_UNAME"],
                    os.environ["SNOW_INSTANCE_PWD"],
                )
            else:
                raise ValueError(
                    f"Please provide ServiceNow credentials (you can use the environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD)"
                )

        # remove trailing slashes in the URL, if any
        self.snow_url = snow_url.rstrip("/")
        self.snow_credentials = snow_credentials
        self.check_status()

    def check_status(self):
        """
        Check the status of the ServiceNow instance. Raises an error if the instance is not ready to be used.

        """
        self._check_is_reachable()
        self._check_is_hibernating()

    def _check_is_hibernating(self):
        """
        Test that the ServiceNow instance is not hibernating

        """
        response = requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)

        # Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."
            )

    def _check_is_reachable(self):
        """
        Test that the ServiceNow instance is reachable

        """
        try:
            requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError(
                f"ServiceNow instance at {self.snow_url} is not reachable. Please check the URL."
            )

    @property
    def release_version(self) -> str:
        """
        Get the release of the ServiceNow instance

        Returns:
        --------
        dict
            Information about the release of the ServiceNow instance

        """
        # XXX: Need to include the import here to avoid circular imports
        from .utils import ui_login

        keys = ["build name", "build date", "build tag", "connected to cluster node"]

        # We need to use playwright since the page is loaded dynamically
        # and its source doesn't contain the information we need
        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(headless=True)
            page = browser.new_page()
            ui_login(self, page)
            page.goto(self.snow_url + "/stats.do")

            # The page contains a big list of information separated by
 tags. Extract it.
            release_info = {
                key.strip(): value.strip()
                for x in page.content().lower().split("
")
                if ":" in x
                for key, value in [x.split(":", 1)]
                if key in keys
            }
            browser.close()

        return release_info

    @property
    def report_filter_config(self) -> dict:
        """
        Get the report filter configuration from the ServiceNow instance.

        Returns:
        --------
        dict
            The report filter configuration, or an empty dictionary if not found.

        """
        from .api.system_properties import (
            get_sys_property,
        )  # Import here to avoid circular import issues

        try:
            config = get_sys_property(self, REPORT_FILTER_PROPERTY)
            config = json.loads(config)
            return config
        except Exception:
            return None


import json
import os
import requests
import re

from playwright.sync_api import sync_playwright
from typing import Optional

from .config import SNOW_BROWSER_TIMEOUT, REPORT_FILTER_PROPERTY

class SNowInstance:
    """
    Utility class to access a ServiceNow instance.

    """

    def __init__(
        self,
        snow_url: Optional[str] = None,
        snow_credentials: Optional[tuple[str, str]] = None,
    ) -> None:
        """
        Set up a ServiceNow instance API

        Parameters:
        -----------
        snow_url: str
            The URL of a SNow instance. If None, will try to get the value from the environment variable SNOW_INSTANCE_URL.
        snow_credentials: (str, str)
            The username and password used to access the SNow instance. If None, will try to get the values from the
            environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD.

        """
        # try to get these values from environment variables if not provided
        if snow_url is None:
            if "SNOW_INSTANCE_URL" in os.environ:
                snow_url = os.environ["SNOW_INSTANCE_URL"]
            else:
                raise ValueError(
                    f"Please provide a ServiceNow instance URL (you can use the environment variable SNOW_INSTANCE_URL)"
                )

        if snow_credentials is None:
            if "SNOW_INSTANCE_UNAME" in os.environ and "SNOW_INSTANCE_PWD" in os.environ:
                snow_credentials = (
                    os.environ["SNOW_INSTANCE_UNAME"],
                    os.environ["SNOW_INSTANCE_PWD"],
                )
            else:
                raise ValueError(
                    f"Please provide ServiceNow credentials (you can use the environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD)"
                )

        # remove trailing slashes in the URL, if any
        self.snow_url = snow_url.rstrip("/")
        self.snow_credentials = snow_credentials
        self.check_status()

    def check_status(self):
        """
        Check the status of the ServiceNow instance. Raises an error if the instance is not ready to be used.

        """
        self._check_is_reachable()
        self._check_is_hibernating()

    def _check_is_hibernating(self):
        """
        Test that the ServiceNow instance is not hibernating

        """
        response = requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)

        # Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."


                raise ValueError(
                    f"Please provide a ServiceNow instance URL (you can use the environment variable SNOW_INSTANCE_URL)"
                )

        if snow_credentials is None:
            if "SNOW_INSTANCE_UNAME" in os.environ and "SNOW_INSTANCE_PWD" in os.environ:
                snow_credentials = (
                    os.environ["SNOW_INSTANCE_UNAME"],
                    os.environ["SNOW_INSTANCE_PWD"],
                )
            else:
                raise ValueError(
                    f"Please provide ServiceNow credentials (you can use the environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD)"
                )

        # remove trailing slashes in the URL, if any
        self.snow_url = snow_url.rstrip("/")
        self.snow_credentials = snow_credentials
        self.check_status()

    def check_status(self):
        """
        Check the status of the ServiceNow instance. Raises an error if the instance is not ready to be used.

        """
        self._check_is_reachable()
        self._check_is_hibernating()

    def _check_is_hibernating(self):
        """
        Test that the ServiceNow instance is not hibernating

        """
        response = requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)

        # Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."
            )

    def _check_is_reachable(self):
        """
        Test that the ServiceNow instance is reachable

        """
        try:


                    os.environ["SNOW_INSTANCE_PWD"],
                )
            else:
                raise ValueError(
                    f"Please provide ServiceNow credentials (you can use the environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD)"
                )

        # remove trailing slashes in the URL, if any
        self.snow_url = snow_url.rstrip("/")
        self.snow_credentials = snow_credentials
        self.check_status()

    def check_status(self):
        """
        Check the status of the ServiceNow instance. Raises an error if the instance is not ready to be used.

        """
        self._check_is_reachable()
        self._check_is_hibernating()

    def _check_is_hibernating(self):
        """
        Test that the ServiceNow instance is not hibernating

        """
        response = requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)

        # Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."
            )

    def _check_is_reachable(self):
        """
        Test that the ServiceNow instance is reachable

        """
        try:
            requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError(
                f"ServiceNow instance at {self.snow_url} is not reachable. Please check the URL."
            )

    @property
    def release_version(self) -> str:
        """
        Get the release of the ServiceNow instance

        Returns:
        --------


        """
        Check the status of the ServiceNow instance. Raises an error if the instance is not ready to be used.

        """
        self._check_is_reachable()
        self._check_is_hibernating()

    def _check_is_hibernating(self):
        """
        Test that the ServiceNow instance is not hibernating

        """
        response = requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)

        # Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."
            )

    def _check_is_reachable(self):
        """
        Test that the ServiceNow instance is reachable

        """
        try:
            requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError(
                f"ServiceNow instance at {self.snow_url} is not reachable. Please check the URL."
            )

    @property
    def release_version(self) -> str:
        """
        Get the release of the ServiceNow instance

        Returns:
        --------
        dict
            Information about the release of the ServiceNow instance

        """
        # XXX: Need to include the import here to avoid circular imports
        from .utils import ui_login

        keys = ["build name", "build date", "build tag", "connected to cluster node"]

        # We need to use playwright since the page is loaded dynamically
        # and its source doesn't contain the information we need
        with sync_playwright() as playwright:


# Check if the response contains any indication of the instance being in hibernation
        if "hibernating" in response.text.lower():
            raise RuntimeError(
                f"ServiceNow instance is hibernating. Please navigate to {self.snow_url} wake it up."
            )

    def _check_is_reachable(self):
        """
        Test that the ServiceNow instance is reachable

        """
        try:
            requests.get(self.snow_url, timeout=SNOW_BROWSER_TIMEOUT)
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError(
                f"ServiceNow instance at {self.snow_url} is not reachable. Please check the URL."
            )

    @property
    def release_version(self) -> str:
        """
        Get the release of the ServiceNow instance

        Returns:
        --------
        dict
            Information about the release of the ServiceNow instance

        """
        # XXX: Need to include the import here to avoid circular imports
        from .utils import ui_login

        keys = ["build name", "build date", "build tag", "connected to cluster node"]

        # We need to use playwright since the page is loaded dynamically
        # and its source doesn't contain the information we need
        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(headless=True)
            page = browser.new_page()
            ui_login(self, page)
            page.goto(self.snow_url + "/stats.do")

            # The page contains a big list of information separated by
 tags. Extract it.
            release_info = {
                key.strip(): value.strip()
                for x in page.content().lower().split("
")
                if ":" in x
                for key, value in [x.split(":", 1)]
                if key in keys
            }
            browser.close()

        return release_info

    @property
    def report_filter_config(self) -> dict:
        """
        Get the report filter configuration from the ServiceNow instance.

        Returns:
        --------
        dict
            The report filter configuration, or an empty dictionary if not found.

        """
        from .api.system_properties import (
            get_sys_property,
        )  # Import here to avoid circular import issues

        try:
            config = get_sys_property(self, REPORT_FILTER_PROPERTY)
            config = json.loads(config)
            return config


# and its source doesn't contain the information we need
        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(headless=True)
            page = browser.new_page()
            ui_login(self, page)
            page.goto(self.snow_url + "/stats.do")

            # The page contains a big list of information separated by
 tags. Extract it.
            release_info = {
                key.strip(): value.strip()
                for x in page.content().lower().split("
")
                if ":" in x
                for key, value in [x.split(":", 1)]
                if key in keys
            }
            browser.close()

        return release_info

    @property
    def report_filter_config(self) -> dict:
        """
        Get the report filter configuration from the ServiceNow instance.

        Returns:
        --------
        dict
            The report filter configuration, or an empty dictionary if not found.

        """
        from .api.system_properties import (
            get_sys_property,
        )  # Import here to avoid circular import issues

        try:
            config = get_sys_property(self, REPORT_FILTER_PROPERTY)
            config = json.loads(config)
            return config
        except Exception:
            return None


src.browsergym.workarena.install

import html
import json
import logging
import re
import tenacity

from datetime import datetime
from playwright.sync_api import sync_playwright
from tenacity import retry, stop_after_attempt, retry_if_exception_type
from requests import HTTPError
from time import sleep

from .api.system_properties import get_sys_property, set_sys_property
from .api.ui_themes import get_workarena_theme_variants
from .api.user import create_user
from .api.utils import table_api_call, table_column_info
from .config import (
    # for knowledge base setup
    KB_FILEPATH,
    KB_NAME,
    PROTOCOL_KB_FILEPATH,
    PROTOCOL_KB_NAME,
    # For list setup
    EXPECTED_ASSET_LIST_COLUMNS_PATH,
    EXPECTED_CHANGE_REQUEST_COLUMNS_PATH,
    EXPECTED_EXPENSE_LINE_COLUMNS_PATH,
    EXPECTED_HARDWARE_COLUMNS_PATH,
    EXPECTED_INCIDENT_COLUMNS_PATH,
    EXPECTED_PROBLEM_COLUMNS_PATH,
    EXPECTED_REQUESTED_ITEMS_COLUMNS_PATH,
    EXPECTED_SERVICE_CATALOG_COLUMNS_PATH,
    EXPECTED_USER_COLUMNS_PATH,
    # for form setup
    EXPECTED_CHANGE_REQUEST_FORM_FIELDS_PATH,
    EXPECTED_HARDWARE_FORM_FIELDS_PATH,
    EXPECTED_INCIDENT_FORM_FIELDS_PATH,
    EXPECTED_PROBLEM_FORM_FIELDS_PATH,
    EXPECTED_REQUEST_ITEM_FORM_FIELDS_PATH,
    EXPECTED_USER_FORM_FIELDS_PATH,
    # Patch flag for reports
    REPORT_PATCH_FLAG,
    REPORT_FILTER_PROPERTY,
    # Supported ServiceNow releases
    SNOW_SUPPORTED_RELEASES,
    # For workflows setup
    WORKFLOWS,
    # For UI themes setup
    UI_THEMES_UPDATE_SET,
)
from .api.user import set_user_preference
from .instance import SNowInstance
from .utils import url_login

def _is_dev_portal_instance() -> bool:
    """
    Check if the instance is a ServiceNow Developer Portal instance.

    Returns:
    --------
    bool: True if the instance is a developer portal instance, False otherwise.

    """
    instance = SNowInstance()
    # Check if the instance url has the for devXXXXXX.service-now.com format (where X is a digit)
    if re.match(r"^https?://dev\d{6}\.service-now\.com", instance.snow_url):
        logging.info("Detected a developer portal instance...")
        return True
    logging.info("Detected an internal instance...")
    return False

def _install_update_set(path: str, name: str):
    """
    Install a ServiceNow update set

    Parameters:
    -----------
    path: str
        The path to the update set file.
    name: str
        The name of the update set as it should appear in the UI.

    Notes: requires interacting with the UI, so we use playwright instead of the API

    """
    with sync_playwright() as playwright:
        instance = SNowInstance()
        browser = playwright.chromium.launch(headless=True, slow_mo=1000)
        page = browser.new_page()
        url_login(instance, page)

        # Navigate to the update set upload page and upload all update sets
        logging.info("Uploading update set...")
        page.goto(
            instance.snow_url
            + "/now/nav/ui/classic/params/target/upload.do%3Fsysparm_referring_url%3Dsys_remote_update_set_list.do%253Fsysparm_fixed_query%253Dsys_class_name%253Dsys_remote_update_set%26sysparm_target%3Dsys_remote_update_set"
        )
        iframe = page.wait_for_selector('iframe[name="gsft_main"]').content_frame()
        with page.expect_file_chooser() as fc_info:
            iframe.locator("#attachFile").click()
        file_chooser = fc_info.value
        file_chooser.set_files(path)
        iframe.locator("input:text('Upload')").click()
        sleep(5)

        # Apply all update sets
        logging.info("Applying update set...")
        # ... retrieve all update sets that are ready to be applied
        update_set = table_api_call(
            instance=instance,
            table="sys_remote_update_set",
            params={
                "sysparm_query": f"name={name}^state=loaded",
            },
        )["result"][0]
        # ... apply them
        logging.info(f"... {update_set['name']}")
        page.goto(instance.snow_url + "/sys_remote_update_set.do?sys_id=" + update_set["sys_id"])
        page.locator("button:has-text('Preview Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")
        # click escape to close popup
        page.keyboard.press("Escape")
        page.locator("button:has-text('Commit Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")

        browser.close()

def check_knowledge_base(
    instance: SNowInstance, kb_name: str, kb_data: dict, disable_commenting: bool = True
):
    """
    Verify the integrity of the knowledge base in the instance.
    Args:
    -----
    instance: SNowInstance
        The ServiceNow instance to check the knowledge base in
    kb_name: str
        The name of the knowledge base to check
    kb_data: dict
        The knowledge base data to check
    disable_commenting: bool
        Whether to disable commenting on the knowledge base

    """

    def _extract_text(article):
        article = html.unescape(article)  # replace special chars
        article = re.sub(r"<[^>]+>", "", article)  # remove html tags
        article = "".join([c for c in article if c.isalnum()])  # extract alphanum only
        return article

    # Check that a knowledge base with the correct name exists
    kb = table_api_call(
        instance=instance,
        table="kb_knowledge_base",
        params={"sysparm_query": f"title={kb_name}"},
    )["result"]

    # The KB exists
    if len(kb) == 1:
        requires_install = False
        requires_delete = False

        # Check that the KB has the correct settings
        if disable_commenting and (
            kb[0]["disable_commenting"] != "true"
            or kb[0]["disable_mark_as_helpful"] != "true"
            or kb[0]["disable_rating"] != "true"
            or kb[0]["disable_suggesting"] != "true"
            or kb[0]["disable_category_editing"] != "true"
        ):
            requires_install = True
            requires_delete = True

        # Get all articles in the KB
        articles = table_api_call(
            instance=instance,
            table="kb_knowledge",
            params={"sysparm_query": f"kb_knowledge_base={kb[0]['sys_id']}"},
        )["result"]
        if len(articles) != len(kb_data):
            requires_install = True
            requires_delete = True
        else:
            for a in articles:
                try:
                    # Parse the article title (by convention, articles are named "Article <number>" and 1-indexed)
                    idx = int(a["short_description"].split(" ")[1]) - 1
                except:
                    # Invalid article title, the KB is corrupt and must be reinstalled
                    requires_install = True
                    requires_delete = True
                    break

                # Check that the articles match (preprocess the text because ServiceNow adds some HTML tags)
                if _extract_text(kb_data[idx]["article"]) != _extract_text(a["text"]):
                    requires_install = True
                    requires_delete = True
                    break

    # There are more than one KB with the expected name (corrupt install)
    elif len(kb) > 1:
        raise Exception(
            "Multiple knowledge bases with the same name found. The instance is in an unexpected state."
        )

    # The KB doesn't exist and must be installed
    else:
        requires_install = True
        requires_delete = False

    return (
        kb[0]["sys_id"] if len(kb) == 1 else None,
        requires_install,
        requires_delete,
    )

def delete_knowledge_base(instance: SNowInstance, kb_id: str, kb_name: str):
    """
    Delete a knowledge base from the instance.

    Notes: will delete all content, but will only archive the KB since ServiceNow prevents deletion.

    """
    articles = table_api_call(
        instance=instance,
        table="kb_knowledge",
        params={"sysparm_query": f"kb_knowledge_base={kb_id}"},
    )["result"]

    # Delete the knowledge base
    logging.info(f"Knowledge base {kb_name}: deleting knowledge base content")
    for a_ in articles:
        table_api_call(instance=instance, table=f"kb_knowledge/{a_['sys_id']}", method="DELETE")

    # Rename the KB and set active=False (ServiceNow prevents deletion)
    logging.info(f"Knowledge base {kb_name}: archiving knowledge base")
    table_api_call(
        instance=instance,
        table=f"kb_knowledge_base/{kb_id}",
        method="PATCH",
        json={"title": f"archived_{kb_id}", "active": "false"},
    )

def create_knowledge_base(
    instance: SNowInstance,
    kb_name: str,
    kb_data: dict,
    disable_commenting: bool = True,
    add_article_name: bool = False,
):
    """
    Create knowledge base and upload all articles.
    Params:
    -------
    instance: SNowInstance
        The ServiceNow instance to install the knowledge base in
    kb_name: str
        The name of the knowledge base that will be created
    kb_data: dict
        The knowledge base data to upload
    disable_commenting: bool
        Whether to disable commenting on the knowledge base
    add_article_name: bool
        Whether to add the article name to the article text. If False, the articles will be named "Article <number>"
        Otherwise, we will extract the article title from the 'item' field in the JSON file.

    """
    logging.info(f"Installing knowledge base {kb_name}...")

    # Create the knowledge base
    logging.info(f"... creating knowledge base {kb_name}")
    disable_commenting = "true" if disable_commenting else "false"

    kb = table_api_call(
        instance=instance,
        table="kb_knowledge_base",
        method="POST",
        data=json.dumps(
            {
                "title": kb_name,
                "disable_commenting": disable_commenting,
                "disable_mark_as_helpful": disable_commenting,
                "disable_rating": disable_commenting,
                "disable_suggesting": disable_commenting,
                "disable_category_editing": disable_commenting,
            }
        ),
    )["result"]
    kb_id = kb["sys_id"]

    for i, kb_entry in enumerate(kb_data):
        logging.info(f"... Knowledge Base {kb_name} uploading article {i + 1}/{len(kb_data)}")
        article = kb_entry["article"]
        if add_article_name:
            short_description = kb_entry["item"]
        else:
            short_description = f"Article {i + 1}"
        # Plant a new article in kb_knowledge table
        table_api_call(
            instance,
            table="kb_knowledge",
            method="POST",
            data=json.dumps(
                {
                    "short_description": short_description,
                    "sys_class_name": "kb_knowledge",
                    "text": article,
                    "article_type": "text",
                    "kb_knowledge_base": kb_id,
                }
            ),
        )

def setup_knowledge_bases():
    """
    Verify that the knowledge base is installed correctly in the instance.
    If it is not, it will be installed.

    """
    # Get the ServiceNow instance
    instance = SNowInstance()
    # Mapping between knowledge base name and filepath + whether or not to disable comments + whether or not to add article name
    knowledge_bases = {
        KB_NAME: (KB_FILEPATH, True, False),
        PROTOCOL_KB_NAME: (PROTOCOL_KB_FILEPATH, True, True),
    }
    for kb_name, (kb_filepath, disable_commenting, add_article_name) in knowledge_bases.items():
        # Load the knowledge base
        with open(kb_filepath, "r") as f:
            kb_data = json.load(f)

        kb_id, requires_install, requires_delete = check_knowledge_base(
            instance=instance,
            kb_name=kb_name,
            kb_data=kb_data,
            disable_commenting=disable_commenting,
        )

        # Delete knowledge base if needed
        if requires_delete:
            logging.info(f"Knowledge base {kb_name} is corrupt. Reinstalling...")
            delete_knowledge_base(instance=instance, kb_id=kb_id, kb_name=kb_name)

        # Install the knowledge base
        if requires_install:
            create_knowledge_base(
                instance=instance,
                kb_name=kb_name,
                kb_data=kb_data,
                disable_commenting=disable_commenting,
                add_article_name=add_article_name,
            )

            # Confirm that the knowledge base was installed correctly
            kb_id, requires_install, requires_delete = check_knowledge_base(
                instance=instance, kb_name=kb_name, kb_data=kb_data
            )
            assert (
                not requires_install or requires_delete
            ), f"Knowledge base {kb_name} installation failed."
            logging.info(f"Knowledge base {kb_name} installation succeeded.")

        if not requires_delete and not requires_install:
            logging.info(f"Knowledge base {kb_name} is already installed.")

def setup_workflows():
    """
    Verify that workflows are correctly installed.
    If not, install them.

    """
    if not check_workflows_installed():
        install_workflows()
        assert check_workflows_installed(), "Workflow installation failed."
        logging.info("Workflow installation succeeded.")

def check_workflows_installed():
    """
    Check if the workflows are installed in the instance.

    Will return False if workflows need to be (re)installed. True if all is good.

    """
    expected_workflow_names = [x["name"] for x in WORKFLOWS.values()]
    workflows = table_api_call(
        instance=SNowInstance(),
        table="wf_workflow",
        params={
            "sysparm_query": "nameIN" + ",".join(expected_workflow_names),
        },
    )["result"]

    # Verify that all workflows are installed
    if len(workflows) != len(WORKFLOWS):
        logging.info(
            f"Missing workflows: {set(expected_workflow_names) - set([w['name'] for w in workflows])}."
        )
        return False

    logging.info("All workflows are installed properly.")
    return True

def install_workflows():
    """
    Install workflows using ServiceNow update sets.

    Notes: requires interacting with the UI, so we use playwright instead of the API

    """
    logging.info("Installing workflow update sets...")
    for wf in WORKFLOWS.values():
        _install_update_set(path=wf["update_set"], name=wf["name"])

def display_all_expected_columns(
    instance: SNowInstance, list_name: str, expected_columns: list[str]
):
    """
    Display all expected columns in a given list view.

    Parameters:
    -----------
    instance: SNowInstance
        The ServiceNow instance to configure.
    list_name: str
        The name of the list to display columns for.
    expected_columns: list[str]
        The list of columns to display.

    """
    logging.info(f"... Setting up default view for list {list_name}")

    # Get the default view (for all users)
    logging.info(f"...... Fetching default view for list {list_name}...")
    default_view = table_api_call(
        instance=instance,
        table="sys_ui_list",
        params={
            "sysparm_query": f"name={list_name}^view.title=Default View^sys_userISEMPTY^parentISEMPTY",
            "sysparm_fields": "sys_id,name,view.title,sys_user",
        },
    )["result"]

    # If there is more than one, delete all but the one with the most recently updated
    if len(default_view) > 1:
        logging.info(
            f"......... Multiple default views found for list {list_name}. Deleting all but the most recent one."
        )
        default_view = sorted(default_view, key=lambda x: x["sys_updated_on"], reverse=True)
        # Delete all but the first one
        for view in default_view[1:]:
            logging.info(f"............ Deleting view {view['sys_id']}")
            table_api_call(
                instance=instance, table=f"sys_ui_list/{view['sys_id']}", method="DELETE"
            )
    default_view = default_view[0]

    # Find all columns in the view (get their sysid)
    logging.info(f"...... Fetching existing columns for default view of list {list_name}...")
    columns = table_api_call(
        instance=instance,
        table="sys_ui_list_element",
        params={"sysparm_query": f"list_id={default_view['sys_id']}", "sysparm_fields": "sys_id"},
    )["result"]

    # Delete all columns in the default view
    logging.info(f"...... Deleting existing columns for default view of list {list_name}...")
    for column in columns:
        table_api_call(
            instance=instance, table=f"sys_ui_list_element/{column['sys_id']}", method="DELETE"
        )

    # Add all expected columns to the default view
    logging.info(f"...... Adding expected columns to default view of list {list_name}...")
    for i, column in enumerate(expected_columns):
        logging.info(f"......... {column}")
        table_api_call(
            instance=instance,
            table="sys_ui_list_element",
            method="POST",
            data=json.dumps({"list_id": default_view["sys_id"], "element": column, "position": i}),
        )
    logging.info(f"...... Done.")

def check_all_columns_displayed(
    instance: SNowInstance, url: str, expected_columns: list[str]
) -> bool:
    """
    Get the visible columns and checks that all expected columns are displayed.

    Parameters:
    -----------
    instance: SNowInstance
        The ServiceNow instance to configure.
    url: str
        The URL of the list view to check.
    expected_columns: list[str]
        The set of columns to check for.

    Returns:
    --------
    bool: True if all expected columns are displayed, False otherwise.

    """
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=True, slow_mo=1000)
        page = browser.new_page()
        url_login(instance, page)
        page.goto(instance.snow_url + url)
        iframe = page.wait_for_selector("iframe#gsft_main").content_frame()
        # Wait for gsft_main.GlideList2 to be available
        page.wait_for_function("typeof gsft_main.GlideList2 !== 'undefined'")
        lst = iframe.locator("table.data_list_table")

        # Validate the number of lists on the page
        lst = lst.nth(0)
        js_selector = f"gsft_main.GlideList2.get('{lst.get_attribute('data-list_id')}')"
        visible_columns = set(page.evaluate(f"{js_selector}.fields").split(","))

        # check if expected columns is contained in the visible columns
        if not set(expected_columns).issubset(visible_columns):
            logging.info(
                f"Error setting up list at {url} \n Expected {expected_columns} columns, but got {visible_columns}."
            )
            return False
        logging.info(f"All columns properly displayed for {url}.")
        return True

def setup_list_columns():
    """
    Setup the list view to display the expected number of columns.

    """
    logging.info("Setting up visible list columns...")
    list_mappings = {
        "alm_asset": {
            "url": "/now/nav/ui/classic/params/target/alm_asset_list.do",
            "expected_columns_path": EXPECTED_ASSET_LIST_COLUMNS_PATH,
        },
        "alm_hardware": {
            "url": "/now/nav/ui/classic/params/target/alm_hardware_list.do",
            "expected_columns_path": EXPECTED_HARDWARE_COLUMNS_PATH,
        },
        "change_request": {
            "url": "/now/nav/ui/classic/params/target/change_request_list.do",
            "expected_columns_path": EXPECTED_CHANGE_REQUEST_COLUMNS_PATH,
        },
        "incident": {
            "url": "/now/nav/ui/classic/params/target/incident_list.do",
            "expected_columns_path": EXPECTED_INCIDENT_COLUMNS_PATH,
        },
        "problem": {
            "url": "/now/nav/ui/classic/params/target/problem_list.do",
            "expected_columns_path": EXPECTED_PROBL
# ... truncated ...


    EXPECTED_HARDWARE_FORM_FIELDS_PATH,
    EXPECTED_INCIDENT_FORM_FIELDS_PATH,
    EXPECTED_PROBLEM_FORM_FIELDS_PATH,
    EXPECTED_REQUEST_ITEM_FORM_FIELDS_PATH,
    EXPECTED_USER_FORM_FIELDS_PATH,
    # Patch flag for reports
    REPORT_PATCH_FLAG,
    REPORT_FILTER_PROPERTY,
    # Supported ServiceNow releases
    SNOW_SUPPORTED_RELEASES,
    # For workflows setup
    WORKFLOWS,
    # For UI themes setup
    UI_THEMES_UPDATE_SET,
)
from .api.user import set_user_preference
from .instance import SNowInstance
from .utils import url_login

def _is_dev_portal_instance() -> bool:
    """
    Check if the instance is a ServiceNow Developer Portal instance.

    Returns:
    --------
    bool: True if the instance is a developer portal instance, False otherwise.

    """
    instance = SNowInstance()
    # Check if the instance url has the for devXXXXXX.service-now.com format (where X is a digit)
    if re.match(r"^https?://dev\d{6}\.service-now\.com", instance.snow_url):
        logging.info("Detected a developer portal instance...")
        return True
    logging.info("Detected an internal instance...")
    return False

def _install_update_set(path: str, name: str):
    """
    Install a ServiceNow update set

    Parameters:
    -----------
    path: str
        The path to the update set file.
    name: str
        The name of the update set as it should appear in the UI.

    Notes: requires interacting with the UI, so we use playwright instead of the API

    """
    with sync_playwright() as playwright:
        instance = SNowInstance()
        browser = playwright.chromium.launch(headless=True, slow_mo=1000)
        page = browser.new_page()


def _is_dev_portal_instance() -> bool:
    """
    Check if the instance is a ServiceNow Developer Portal instance.

    Returns:
    --------
    bool: True if the instance is a developer portal instance, False otherwise.

    """
    instance = SNowInstance()
    # Check if the instance url has the for devXXXXXX.service-now.com format (where X is a digit)
    if re.match(r"^https?://dev\d{6}\.service-now\.com", instance.snow_url):
        logging.info("Detected a developer portal instance...")
        return True
    logging.info("Detected an internal instance...")
    return False

def _install_update_set(path: str, name: str):
    """
    Install a ServiceNow update set

    Parameters:
    -----------
    path: str
        The path to the update set file.
    name: str
        The name of the update set as it should appear in the UI.

    Notes: requires interacting with the UI, so we use playwright instead of the API

    """
    with sync_playwright() as playwright:
        instance = SNowInstance()
        browser = playwright.chromium.launch(headless=True, slow_mo=1000)
        page = browser.new_page()
        url_login(instance, page)

        # Navigate to the update set upload page and upload all update sets
        logging.info("Uploading update set...")
        page.goto(
            instance.snow_url
            + "/now/nav/ui/classic/params/target/upload.do%3Fsysparm_referring_url%3Dsys_remote_update_set_list.do%253Fsysparm_fixed_query%253Dsys_class_name%253Dsys_remote_update_set%26sysparm_target%3Dsys_remote_update_set"
        )
        iframe = page.wait_for_selector('iframe[name="gsft_main"]').content_frame()
        with page.expect_file_chooser() as fc_info:
            iframe.locator("#attachFile").click()
        file_chooser = fc_info.value
        file_chooser.set_files(path)
        iframe.locator("input:text('Upload')").click()
        sleep(5)

        # Apply all update sets
        logging.info("Applying update set...")
        # ... retrieve all update sets that are ready to be applied
        update_set = table_api_call(
            instance=instance,
            table="sys_remote_update_set",
            params={
                "sysparm_query": f"name={name}^state=loaded",
            },
        )["result"][0]
        # ... apply them
        logging.info(f"... {update_set['name']}")
        page.goto(instance.snow_url + "/sys_remote_update_set.do?sys_id=" + update_set["sys_id"])
        page.locator("button:has-text('Preview Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")
        # click escape to close popup
        page.keyboard.press("Escape")
        page.locator("button:has-text('Commit Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")

        browser.close()

def check_knowledge_base(
    instance: SNowInstance, kb_name: str, kb_data: dict, disable_commenting: bool = True
):
    """
    Verify the integrity of the knowledge base in the instance.
    Args:
    -----
    instance: SNowInstance
        The ServiceNow instance to check the knowledge base in
    kb_name: str
        The name of the knowledge base to check
    kb_data: dict
        The knowledge base data to check
    disable_commenting: bool
        Whether to disable commenting on the knowledge base

    """


        update_set = table_api_call(
            instance=instance,
            table="sys_remote_update_set",
            params={
                "sysparm_query": f"name={name}^state=loaded",
            },
        )["result"][0]
        # ... apply them
        logging.info(f"... {update_set['name']}")
        page.goto(instance.snow_url + "/sys_remote_update_set.do?sys_id=" + update_set["sys_id"])
        page.locator("button:has-text('Preview Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")
        # click escape to close popup
        page.keyboard.press("Escape")
        page.locator("button:has-text('Commit Update Set')").first.click()
        page.wait_for_selector("text=Succeeded")

        browser.close()

def check_knowledge_base(
    instance: SNowInstance, kb_name: str, kb_data: dict, disable_commenting: bool = True
):
    """
    Verify the integrity of the knowledge base in the instance.
    Args:
    -----
    instance: SNowInstance
        The ServiceNow instance to check the knowledge base in
    kb_name: str
        The name of the knowledge base to check
    kb_data: dict
        The knowledge base data to check
    disable_commenting: bool
        Whether to disable commenting on the knowledge base

    """

    def _extract_text(article):
        article = html.unescape(article)  # replace special chars
        article = re.sub(r"<[^>]+>", "", article)  # remove html tags
        article = "".join([c for c in article if c.isalnum()])  # extract alphanum only
        return article

    # Check that a knowledge base with the correct name exists
    kb = table_api_call(
        instance=instance,
        table="kb_knowledge_base",
        params={"sysparm_query": f"title={kb_name}"},
    )["result"]

    # The KB exists
    if len(kb) == 1:
        requires_install = False
        requires_delete = False

        # Check that the KB has the correct settings
        if disable_commenting and (
            kb[0]["disable_commenting"] != "true"
            or kb[0]["disable_mark_as_helpful"] != "true"
            or kb[0]["disable_rating"] != "true"
            or kb[0]["disable_suggesting"] != "true"
            or kb[0]["disable_category_editing"] != "true"
        ):
            requires_install = True
            requires_delete = True

        # Get all articles in the KB
        articles = table_api_call(
            instance=instance,
            table="kb_knowledge",
            params={"sysparm_query": f"kb_knowledge_base={kb[0]['sys_id']}"},
        )["result"]
        if len(articles) != len(kb_data):
            requires_install = True
            requires_delete = True
        else:
            for a in articles:
                try:
                    # Parse the article title (by convention, articles are named "Article <number>" and 1-indexed)
                    idx = int(a["short_description"].split(" ")[1]) - 1
                except:
                    # Invalid article title, the KB is corrupt and must be reinstalled
                    requires_install = True
                    requires_delete = True
                    break

                # Check that the articles match (preprocess the text because ServiceNow adds some HTML tags)
                if _extract_text(kb_data[idx]["article"]) != _extract_text(a["text"]):
                    requires_install = True
                    requires_delete = True
                    break

    # There are more than one KB with the expected name (corrupt install)
    elif len(kb) > 1:
        raise Exception(
            "Multiple knowledge bases with the same name found. The instance is in an unexpected state."
        )

    # The KB doesn't exist and must be installed
    else:
        requires_install = True
        requires_delete = False

    return (
        kb[0]["sys_id"] if len(kb) == 1 else None,
        requires_install,
        requires_delete,
    )

def delete_knowledge_base(instance: SNowInstance, kb_id: str, kb_name: str):
    """
    Delete a knowledge base from the instance.

    Notes: will delete all content, but will only archive the KB since ServiceNow prevents deletion.

    """
    articles = table_api_call(
        instance=instance,
        table="kb_knowledge",
        params={"sysparm_query": f"kb_knowledge_base={kb_id}"},
    )["result"]

    # Delete the knowledge base
    logging.info(f"Knowledge base {kb_name}: deleting knowledge base content")
    for a_ in articles:
        table_api_call(instance=instance, table=f"kb_knowledge/{a_['sys_id']}", method="DELETE")


                    break

    # There are more than one KB with the expected name (corrupt install)
    elif len(kb) > 1:
        raise Exception(
            "Multiple knowledge bases with the same name found. The instance is in an unexpected state."
        )

    # The KB doesn't exist and must be installed
    else:
        requires_install = True
        requires_delete = False

    return (
        kb[0]["sys_id"] if len(kb) == 1 else None,
        requires_install,
        requires_delete,
    )

def delete_knowledge_base(instance: SNowInstance, kb_id: str, kb_name: str):
    """
    Delete a knowledge base from the instance.

    Notes: will delete all content, but will only archive the KB since ServiceNow prevents deletion.

    """
    articles = table_api_call(
        instance=instance,
        table="kb_knowledge",
        params={"sysparm_query": f"kb_knowledge_base={kb_id}"},
    )["result"]

    # Delete the knowledge base
    logging.info(f"Knowledge base {kb_name}: deleting knowledge base content")
    for a_ in articles:
        table_api_call(instance=instance, table=f"kb_knowledge/{a_['sys_id']}", method="DELETE")

    # Rename the KB and set active=False (ServiceNow prevents deletion)
    logging.info(f"Knowledge base {kb_name}: archiving knowledge base")
    table_api_call(
        instance=instance,
        table=f"kb_knowledge_base/{kb_id}",
        method="PATCH",
        json={"title": f"archived_{kb_id}", "active": "false"},
    )

def create_knowledge_base(
    instance: SNowInstance,
    kb_name: str,
    kb_data: dict,
    disable_commenting: bool = True,
    add_article_name: bool = False,
):
    """
    Create knowledge base and upload all articles.
    Params:
    -------
    instance: SNowInstance
        The ServiceNow instance to install the knowledge base in
    kb_name: str
        The name of the knowledge base that will be created
    kb_data: dict
        The knowledge base data to upload
    disable_commenting: bool


        instance=instance,
        table="kb_knowledge",
        params={"sysparm_query": f"kb_knowledge_base={kb_id}"},
    )["result"]

    # Delete the knowledge base
    logging.info(f"Knowledge base {kb_name}: deleting knowledge base content")
    for a_ in articles:
        table_api_call(instance=instance, table=f"kb_knowledge/{a_['sys_id']}", method="DELETE")

    # Rename the KB and set active=False (ServiceNow prevents deletion)
    logging.info(f"Knowledge base {kb_name}: archiving knowledge base")
    table_api_call(
        instance=instance,
        table=f"kb_knowledge_base/{kb_id}",
        method="PATCH",
        json={"title": f"archived_{kb_id}", "active": "false"},
    )

def create_knowledge_base(
    instance: SNowInstance,
    kb_name: str,
    kb_data: dict,
    disable_commenting: bool = True,
    add_article_name: bool = False,
):
    """
    Create knowledge base and upload all articles.
    Params:
    -------
    instance: SNowInstance
        The ServiceNow instance to install the knowledge base in
    kb_name: str
        The name of the knowledge base that will be created
    kb_data: dict
        The knowledge base data to upload
    disable_commenting: bool
        Whether to disable commenting on the knowledge base
    add_article_name: bool
        Whether to add the article name to the article text. If False, the articles will be named "Article <number>"
        Otherwise, we will extract the article title from the 'item' field in the JSON file.

    """
    logging.info(f"Installing knowledge base {kb_name}...")

    # Create the knowledge base
    logging.info(f"... creating knowledge base {kb_name}")
    disable_commenting = "true" if disable_commenting else "false"

    kb = table_api_call(
        instance=instance,
        table="kb_knowledge_base",
        method="POST",
        data=json.dumps(
            {
                "title": kb_name,
                "disable_commenting": disable_commenting,
                "disable_mark_as_helpful": disable_commenting,
                "disable_rating": disable_commenting,
                "disable_suggesting": disable_commenting,
                "disable_category_editing": disable_commenting,
            }
        ),
    )["result"]
    kb_id = kb["sys_id"]

    for i, kb_entry in enumerate(kb_data):
        logging.info(f"... Knowledge Base {kb_name} uploading article {i + 1}/{len(kb_data)}")
        article = kb_entry["article"]
        if add_article_name:
            short_description = kb_entry["item"]
        else:
            short_description = f"Article {i + 1}"
        # Plant a new article in kb_knowledge table
        table_api_call(
            instance,
            table="kb_knowledge",
            method="POST",
            data=json.dumps(
                {
                    "short_description": short_description,
                    "sys_class_name": "kb_knowledge",
                    "text": article,
                    "article_type": "text",
                    "kb_knowledge_base": kb_id,
                }
            ),
        )

def setup_knowledge_bases():
    """
    Verify that the knowledge base is installed correctly in the instance.
    If it is not, it will be installed.

    """
    # Get the ServiceNow instance
    instance = SNowInstance()
    # Mapping between knowledge base name and filepath + whether or not to disable comments + whether or not to add article name
    knowledge_bases = {
        KB_NAME: (KB_FILEPATH, True, False),
        PROTOCOL_KB_NAME: (PROTOCOL_KB_FILEPATH, True, True),
    }
    for kb_name, (kb_filepath, disable_commenting, add_article_name) in knowledge_bases.items():
        # Load the knowledge base
        with open(kb_filepath, "r") as f:
            kb_data = json.load(f)


            short_description = kb_entry["item"]
        else:
            short_description = f"Article {i + 1}"
        # Plant a new article in kb_knowledge table
        table_api_call(
            instance,
            table="kb_knowledge",
            method="POST",
            data=json.dumps(
                {
                    "short_description": short_description,
                    "sys_class_name": "kb_knowledge",
                    "text": article,
                    "article_type": "text",
                    "kb_knowledge_base": kb_id,
                }
            ),
        )

def setup_knowledge_bases():
    """
    Verify that the knowledge base is installed correctly in the instance.
    If it is not, it will be installed.

    """
    # Get the ServiceNow instance
    instance = SNowInstance()
    # Mapping between knowledge base name and filepath + whether or not to disable comments + whether or not to add article name
    knowledge_bases = {
        KB_NAME: (KB_FILEPATH, True, False),
        PROTOCOL_KB_NAME: (PROTOCOL_KB_FILEPATH, True, True),
    }
    for kb_name, (kb_filepath, disable_commenting, add_article_name) in knowledge_bases.items():
        # Load the knowledge base
        with open(kb_filepath, "r") as f:
            kb_data = json.load(f)

        kb_id, requires_install, requires_delete = check_knowledge_base(
            instance=instance,
            kb_name=kb_name,
            kb_data=kb_data,
            disable_commenting=disable_commenting,
        )

        # Delete knowledge base if needed
        if requires_delete:
            logging.info(f"Knowledge base {kb_name} is corrupt. Reinstalling...")
            delete_knowledge_base(instance=instance, kb_id=kb_id, kb_name=kb_name)

        # Install the knowledge base
        if requires_install:
            create_knowledge_base(
                instance=instance,
                kb_name=kb_name,
                kb_data=kb_data,
                disable_commenting=disable_commenting,
                add_article_name=add_article_name,
            )

            # Confirm that the knowledge base was installed correctly
            kb_id, requires_install, requires_delete = check_knowledge_base(
                instance=instance, kb_name=kb_name, kb_data=kb_data
            )
            assert (
                not requires_install or requires_delete
            ), f"Knowledge base {kb_name} installation failed."
            logging.info(f"Knowledge base {kb_name} installation succeeded.")

        if not requires_delete and not requires_install:
            logging.info(f"Knowledge base {kb_name} is already installed.")

def setup_workflows():
    """
    Verify that workflows are correctly installed.
    If not, install them.

    """
    if not check_workflows_installed():
        install_workflows()
        assert check_workflows_installed(), "Workflow installation failed."
        logging.info("Workflow installation succeeded.")

def check_workflows_installed():
    """
    Check if the workflows are installed in the instance.

    Will return False if workflows need to be (re)installed. True if all is good.