XSOAR Mirroring

Facilitates mirroring of Cortex XSOAR incidents between different Cortex XSOAR tenants.

Utilities · XSOAR Mirroring

Details

IDXSOAR Mirroring
ProviderPalo Alto Networks
CategoryUtilities
From Version6.0.0
Docker Imagedemisto/python3:3.12.13.10230415

README

Facilitates mirroring of Cortex XSOAR incidents between different Cortex XSOAR tenants.
This integration is compatible with Cortex XSOAR versions 6.x and 8.x, and it has been tested for interoperability across the range of Cortex XSOAR versions from 6.12 and lower and 8.4 and lower.

Configure XSOAR Mirroring on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for XSOAR Mirroring.
  3. Click Add instance to create and configure a new integration instance.

    Parameter Description Required
    Incident type   False
    XSOAR Server URL The URL of the Cortex XSOAR server to which you are connecting. True
    API Key The API key to access the server. The key must be provided by the server to which you are connecting. False
    API Key ID The API key to access the server. The key must be provided by the server to which you are connecting. When the target server is XSOAR 8.X, the API Key ID is required as well (not relevant for XSOAR 6.X). False
    Fetch incidents   False
    Maximum number of incidents to pull per fetch   False
    Fetch only incidents that match the query Don’t add created time to the query as this field will be addressed in the “First fetch time”. False
    First fetch time Date or relative timestamp to start fetching incidents from, in the format of <number> <time unit>. For example, 2 minutes, 12 hours, 6 days, 2 weeks, 3 months, 1 year, ISO timestamp. Default is 3 days. False
    Entry Categories Which entries to retrieve from the Cortex XSOAR server. The available options are notes, comments (chats), and files. (attachments). False
    Incoming Entry tags Only entries with these tags are retrieved from the Cortex XSOAR server. If no tags are listed, no entries are retrieved. False
    Outgoing Entry Tags Choose the tags to filter the entries you want to send to the other Cortex XSOAR instance. If no tags are listed, no entries will be sent. False
    Incident Mirroring Direction   False
    Disable fetching for incidents that came from this integration Enable this option to disable mirroring of incidents that came from the integration of XSOAR Mirroring. This adds `-sourceBrand:“XSOAR Mirroring”` to your query. False
    Trust any certificate (not secure)   False
    Use system proxy settings   False
    Debug mode (will print debug logs to info)   False
    Mirror Playbook ID A parameter integration that removes the playbook ID field from incoming incidents. Note: When set to true (default), the instance will attempt to run a playbook according to the incoming ID. When set to false, the instance will run the default playbook for the incident type (if configured locally). False
    Fetch incident history Will mirror historical notes,tags and attachments in case their corresponding incidents were deleted. Notice can impact performance if combined with “Reset the “last run” timestamp” and multiple incidents in system.
    False
  4. To set up the mirroring, enable Fetching incidents in your instance configuration.
  5. In the Incident Mirroring Direction integration parameter, select in which direction the incidents should be mirrored:

    | Option | Description |
    | — | — |
    | None | Turns off incident mirroring. |
    | Incoming | Any changes in XSOAR Mirroring events (mirroring incoming fields) will be reflected in Cortex XSOAR incidents. |
    | Outgoing | Any changes in Cortex XSOAR incidents will be reflected in XSOAR Mirroring events (outgoing mirrored fields). |
    | Incoming And Outgoing | Changes in Cortex XSOAR incidents and XSOAR Mirroring events will be reflected in both directions.
    Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.

    Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex XSOAR and XSOAR Mirroring.

  6. Click Test to ensure that you can communicate with the Cortex XSOAR tenant.

Important notes

  • In order to mirror custom fields, you need to create an incoming mapper for the integration and explicitly specify them in it.
  • In order to mirror custom fields in both directions, the custom fields in both Cortex XSOAR instances must have the same CLI name.
  • Mirrored incidents include the playbook ID. The receiving side will attempt to run a playbook with a matching ID, if one exists locally. To have the machine run the default playbook for the mirrored incident, set the Mirror Playbook ID to false. Otherwise (default), the machine will attempt to run a playbook whose ID matches the playbookId field in the mirrored incident.

Commands

You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

xsoar-search-incidents


Search remote Cortex XSOAR for incidents.

Base Command

xsoar-search-incidents

Input

Argument Name Description Required
query Which incidents to retrieve. Default is -status:closed -category:job. Optional
start_time From when to search. Default is 3 days. Optional
max_results How many incidents to retrieve. Default is 10. Optional
columns Which columns to display. Default is ‘id,name,type,status,severity,owner,occured’. To display all columns, insert ‘all’. Default is id,name,type,status,severity,owner,occured. Optional

Context Output

There is no context output for this command.

Command example

!xsoar-search-incidents query="-status:closed -category:job"

Context Example

{
    "XSOAR": {
        "Incident": {
            "CustomFields": {
                "containmentsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 30,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "detectionsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 20,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "endpoint": [
                    {}
                ],
                "filerelationships": [
                    {},
                    {},
                    {}
                ],
                "isactive": "true",
                "numberofrelatedincidents": 0,
                "numberofsimilarfiles": 0,
                "remediationsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 7200,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "similarincidentsdbot": [
                    {}
                ],
                "timetoassignment": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 0,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "triagesla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 30,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "urlsslverification": []
            },
            "account": "",
            "activated": "0001-01-01T00:00:00Z",
            "attachment": null,
            "autime": 1678010808446441500,
            "cacheVersn": 0,
            "canvases": null,
            "category": "",
            "changeStatus": "new",
            "closeNotes": "",
            "closeReason": "",
            "closed": "0001-01-01T00:00:00Z",
            "closingUserId": "",
            "created": "2023-03-05T10:06:48.446441591Z",
            "dbotCreatedBy": "admin",
            "dbotCurrentDirtyFields": null,
            "dbotDirtyFields": null,
            "dbotMirrorDirection": "",
            "dbotMirrorId": "",
            "dbotMirrorInstance": "",
            "dbotMirrorLastSync": "0001-01-01T00:00:00Z",
            "dbotMirrorTags": null,
            "details": "This is the new details",
            "droppedCount": 0,
            "dueDate": "2023-03-15T10:06:48.446441591Z",
            "feedBased": false,
            "id": "4",
            "insights": 0,
            "investigationId": "4",
            "isDebug": false,
            "isPlayground": false,
            "labels": [
                {
                    "type": "Instance",
                    "value": "admin"
                },
                {
                    "type": "Brand",
                    "value": "Manual"
                }
            ],
            "lastJobRunTime": "0001-01-01T00:00:00Z",
            "lastOpen": "0001-01-01T00:00:00Z",
            "linkedCount": 0,
            "linkedIncidents": null,
            "modified": "2023-03-05T14:43:22.319158041Z",
            "name": "testing",
            "notifyTime": "2023-03-05T10:06:50.261155172Z",
            "occurred": "2023-03-05T10:06:48.446441435Z",
            "openDuration": 0,
            "owner": "admin",
            "parent": "",
            "phase": "",
            "playbookId": "playbook0",
            "rawCategory": "",
            "rawCloseReason": "",
            "rawJSON": "",
            "rawName": "testing",
            "rawPhase": "",
            "rawType": "Unclassified",
            "reason": "",
            "reminder": "0001-01-01T00:00:00Z",
            "runStatus": "waiting",
            "severity": 0,
            "sla": 0,
            "sortValues": [],
            "sourceBrand": "Manual",
            "sourceInstance": "admin",
            "status": 1,
            "type": "Unclassified",
            "version": 13
        }
    }
}

Human Readable Output

Search Results

id name type status severity owner occured
4 testing Unclassified 1 0 admin  

xsoar-get-incident


Retrieve incident and entries from the remote Cortex XSOAR server.

Base Command

xsoar-get-incident

Input

Argument Name Description Required
id The remote incident ID. Required
from_date Retrieve entries that were created after the last update. Default is 3 days. Optional
categories Retrieve only the entries from these categories. Default is chats,notes. Optional
tags Only entries with these tags are retrieved from the Cortex XSOAR server. If no tags are listed, no entries are retrieved. Optional
max_results Max number of entries to retrieve. Default is 10. Optional

Context Output

There is no context output for this command.

Command example

!xsoar-get-incident id=4

Context Example

{
    "XSOAR": {
        "Incident": {
            "CustomFields": {
                "containmentsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 30,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "detectionsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 20,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "endpoint": [
                    {}
                ],
                "filerelationships": [
                    {},
                    {},
                    {}
                ],
                "isactive": "true",
                "numberofrelatedincidents": 0,
                "numberofsimilarfiles": 0,
                "remediationsla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 7200,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "similarincidentsdbot": [
                    {}
                ],
                "timetoassignment": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 0,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "triagesla": {
                    "accumulatedPause": 0,
                    "breachTriggered": false,
                    "dueDate": "0001-01-01T00:00:00Z",
                    "endDate": "0001-01-01T00:00:00Z",
                    "lastPauseDate": "0001-01-01T00:00:00Z",
                    "runStatus": "idle",
                    "sla": 30,
                    "slaStatus": -1,
                    "startDate": "0001-01-01T00:00:00Z",
                    "totalDuration": 0
                },
                "urlsslverification": []
            },
            "account": "",
            "activated": "0001-01-01T00:00:00Z",
            "attachment": null,
            "autime": 1678010808446441500,
            "cacheVersn": 0,
            "canvases": null,
            "category": "",
            "closeNotes": "",
            "closeReason": "",
            "closed": "0001-01-01T00:00:00Z",
            "closingUserId": "",
            "created": "2023-03-05T10:06:48.446441591Z",
            "dbotCreatedBy": "admin",
            "dbotCurrentDirtyFields": null,
            "dbotDirtyFields": null,
            "dbotMirrorDirection": "",
            "dbotMirrorId": "",
            "dbotMirrorInstance": "",
            "dbotMirrorLastSync": "0001-01-01T00:00:00Z",
            "dbotMirrorTags": null,
            "details": "This is the new details",
            "droppedCount": 0,
            "dueDate": "2023-03-15T10:06:48.446441591Z",
            "feedBased": false,
            "id": "4",
            "investigationId": "4",
            "isDebug": false,
            "isPlayground": false,
            "labels": [
                {
                    "type": "Instance",
                    "value": "admin"
                },
                {
                    "type": "Brand",
                    "value": "Manual"
                }
            ],
            "lastJobRunTime": "0001-01-01T00:00:00Z",
            "lastOpen": "0001-01-01T00:00:00Z",
            "linkedCount": 0,
            "linkedIncidents": null,
            "modified": "2023-03-05T14:43:22.319158041Z",
            "name": "testing",
            "notifyTime": "2023-03-05T10:06:50.261155172Z",
            "occurred": "2023-03-05T10:06:48.446441435Z",
            "openDuration": 0,
            "owner": "admin",
            "parent": "",
            "phase": "",
            "playbookId": "playbook0",
            "rawCategory": "",
            "rawCloseReason": "",
            "rawJSON": "",
            "rawName": "testing",
            "rawPhase": "",
            "rawType": "Unclassified",
            "reason": "",
            "reminder": "0001-01-01T00:00:00Z",
            "runStatus": "waiting",
            "severity": 0,
            "sla": 0,
            "sourceBrand": "Manual",
            "sourceInstance": "admin",
            "status": 1,
            "type": "Unclassified",
            "version": 13
        }
    }
}

Human Readable Output

Incident testing

CustomFields account activated attachment autime cacheVersn canvases category closeNotes closeReason closed closingUserId created dbotCreatedBy dbotCurrentDirtyFields dbotDirtyFields dbotMirrorDirection dbotMirrorId dbotMirrorInstance dbotMirrorLastSync dbotMirrorTags details droppedCount dueDate feedBased id investigationId isDebug isPlayground labels lastJobRunTime lastOpen linkedCount linkedIncidents modified name notifyTime occurred openDuration owner parent phase playbookId rawCategory rawCloseReason rawJSON rawName rawPhase rawType reason reminder runStatus severity sla sourceBrand sourceInstance status type version
containmentsla: {“accumulatedPause”: 0, “breachTriggered”: false, “dueDate”: “0001-01-01T00:00:00Z”, “endDate”: “0001-01-01T00:00:00Z”, “lastPauseDate”: “0001-01-01T00:00:00Z”, “runStatus”: “idle”, “sla”: 30, “slaStatus”: -1, “startDate”: “0001-01-01T00:00:00Z”, “totalDuration”: 0}
detectionsla: {“accumulatedPause”: 0, “breachTriggered”: false, “dueDate”: “0001-01-01T00:00:00Z”, “endDate”: “0001-01-01T00:00:00Z”, “lastPauseDate”: “0001-01-01T00:00:00Z”, “runStatus”: “idle”, “sla”: 20, “slaStatus”: -1, “startDate”: “0001-01-01T00:00:00Z”, “totalDuration”: 0}
endpoint: {}
filerelationships: {},
{},
{}
isactive: true
numberofrelatedincidents: 0
numberofsimilarfiles: 0
remediationsla: {“accumulatedPause”: 0, “breachTriggered”: false, “dueDate”: “0001-01-01T00:00:00Z”, “endDate”: “0001-01-01T00:00:00Z”, “lastPauseDate”: “0001-01-01T00:00:00Z”, “runStatus”: “idle”, “sla”: 7200, “slaStatus”: -1, “startDate”: “0001-01-01T00:00:00Z”, “totalDuration”: 0}
similarincidentsdbot: {}
timetoassignment: {“accumulatedPause”: 0, “breachTriggered”: false, “dueDate”: “0001-01-01T00:00:00Z”, “endDate”: “0001-01-01T00:00:00Z”, “lastPauseDate”: “0001-01-01T00:00:00Z”, “runStatus”: “idle”, “sla”: 0, “slaStatus”: -1, “startDate”: “0001-01-01T00:00:00Z”, “totalDuration”: 0}
triagesla: {“accumulatedPause”: 0, “breachTriggered”: false, “dueDate”: “0001-01-01T00:00:00Z”, “endDate”: “0001-01-01T00:00:00Z”, “lastPauseDate”: “0001-01-01T00:00:00Z”, “runStatus”: “idle”, “sla”: 30, “slaStatus”: -1, “startDate”: “0001-01-01T00:00:00Z”, “totalDuration”: 0}
urlsslverification:
  0001-01-01T00:00:00Z   1678010808446441591 0         0001-01-01T00:00:00Z   2023-03-05T10:06:48.446441591Z admin           0001-01-01T00:00:00Z   This is the new details 0 2023-03-15T10:06:48.446441591Z false 4 4 false false {‘value’: ‘admin’, ‘type’: ‘Instance’},
{‘value’: ‘Manual’, ‘type’: ‘Brand’}
0001-01-01T00:00:00Z 0001-01-01T00:00:00Z 0   2023-03-05T14:43:22.319158041Z testing 2023-03-05T10:06:50.261155172Z 2023-03-05T10:06:48.446441435Z 0 admin     playbook0       testing   Unclassified   0001-01-01T00:00:00Z waiting 0 0 Manual admin 1 Unclassified 13

Last entries since 2023-03-02T14:44:47.000Z

brand cacheVersn category contents contentsSize created cronView dbotCreatedBy endingDate format id incidentCreationTime investigationId isTodo mirrored modified note parentEntryTruncated pinned recurrent reputationSize retryTime scheduled startDate times timezoneOffset type user version
none 0 chat done 4 2023-03-05T10:38:21.895125832Z false admin 0001-01-01T00:00:00Z text 52@4 0001-01-01T00:00:00Z 4 false false 2023-03-05T10:38:21.895153311Z false false false false 0 0001-01-01T00:00:00Z false 0001-01-01T00:00:00Z 0 0 1 admin 1

get-remote-data


Get remote data from a remote incident. Note that this method will not update the current incident. It is used for debugging purposes only.

Base Command

get-remote-data

Input

Argument Name Description Required
id The remote incident ID. Required
lastUpdate Retrieve entries that were created after the last update. Optional

Context Output

There is no context output for this command.

get-modified-remote-data


Returns a list of incident IDs that were modified since the last sync. This command is used to enable incremental mirroring and avoid full blind syncs.

Note: This command is supported only for Cortex XSOAR version 8.15 and later.

Base Command

get-modified-remote-data

Input

Argument Name Description Required
lastUpdate ISO8601 timestamp of the last time the integration was synced. Only incidents modified after this time are returned. Required

Context Output

There are no context outputs for this command.

get-mapping-fields


Retrieves the mapping schema from a remote incident.

Base Command

get-mapping-fields

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

There is no context output for this command.

Incident Mirroring

You can enable incident mirroring between Cortex XSOAR incidents and XSOAR Mirroring corresponding events (available from Cortex XSOAR version 6.0.0).
To set up the mirroring:

  1. Enable Fetching incidents in your instance configuration.
  2. In the Mirroring Direction integration parameter, select in which direction the incidents should be mirrored:

    Option Description
    None Turns off incident mirroring.
    Incoming Any changes in XSOAR Mirroring events (mirroring incoming fields) will be reflected in Cortex XSOAR incidents.
    Outgoing Any changes in Cortex XSOAR incidents will be reflected in XSOAR Mirroring events (outgoing mirrored fields).
    Incoming And Outgoing Changes in Cortex XSOAR incidents and XSOAR Mirroring events will be reflected in both directions.

Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.
Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex XSOAR and XSOAR Mirroring.

Configuration parameters

  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • url — XSOAR Server URL (required)
  • apikey — API Key
  • credentials_api_key — API Key ID (for XSOAR 8.x only)
  • isFetch — Fetch incidents
  • max_fetch — Maximum number of incidents to pull per fetch
  • query — Fetch only incidents that match the query
  • first_fetch — First fetch time
  • categories — Entry Categories
  • tags — Incoming Entry tags
  • mirror_tag — Outgoing Entry Tags
  • mirror_direction — Incident Mirroring Direction
  • disable_from_same_integration — Disable fetching for incidents that came from this integration
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • debug_mode — Debug mode (will print debug logs to info)
  • mirror_playbook_id — Mirror Playbook ID
  • fetch_incident_history — Fetch incident history

Commands (5)

  • get-mapping-fields

    Retrieves the mapping schema from a remote incident.

  • get-modified-remote-data

    Returns a list of incident IDs that were modified since the last sync. Used to enable incremental mirroring and avoid full blind syncs.

  • get-remote-data

    Get remote data from a remote incident. Note that this method will not update the current incident. It is used for debugging purposes only.

  • xsoar-get-incident

    Retrieve incident and entries from the remote Cortex XSOAR server.

  • xsoar-search-incidents

    Search remote Cortex XSOAR for incidents.

import json
from datetime import timedelta, UTC
from typing import Any

import dateparser
import demistomock as demisto
import requests
import urllib3
from CommonServerPython import *

# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """


MAX_INCIDENTS_TO_FETCH = 100
FIELDS_TO_COPY_FROM_REMOTE_INCIDENT = [
    "name",
    "rawName",
    "severity",
    "occurred",
    "modified",
    "roles",
    "type",
    "rawType",
    "status",
    "reason",
    "created",
    "closed",
    "sla",
    "labels",
    "attachment",
    "details",
    "openDuration",
    "lastOpen",
    "owner",
    "closeReason",
    "rawCloseReason",
    "closeNotes",
    "playbookId",
    "dueDate",
    "reminder",
    "runStatus",
    "notifyTime",
    "phase",
    "rawPhase",
    "CustomFields",
    "category",
    "rawCategory",
]

MIRROR_DIRECTION = {"None": None, "Incoming": "In", "Outgoing": "Out", "Incoming And Outgoing": "Both"}
XSOAR_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
MIRROR_RESET = "XSOARMirror_mirror_reset"
# Integration-context key used to suppress the /public/v1/incidents/modified endpoint for the rest of the day
# when the remote XSOAR machine does not support it (HTTP 303/404).  Value is an ISO date string "YYYY-MM-DD".
MODIFIED_ENDPOINT_UNSUPPORTED_DATE_KEY = "XSOARMirror_modified_endpoint_unsupported_date"

""" CLIENT CLASS """


class Client(BaseClient):
    def search_incidents(
        self, query: str | None, max_results: int | None, start_time: Union[str, int], page: int = None, field: str = "id"
    ) -> list[dict[str, Any]]:
        data = remove_empty_elements(
            {
                "filter": {
                    "query": query,
                    "size": max_results or 10,
                    "page": page,
                    "fromDate": start_time,
                    "sort": [{"field": field, "asc": True}],
                }
            }
        )
        return self._http_request(method="POST", url_suffix="/incidents/search", json_data=data).get("data")

    def get_incident(self, incident_id: str) -> dict[str, Any]:
        return self._http_request(method="GET", url_suffix=f"/incident/load/{incident_id}")

    def get_file_entry(self, entry_id: str) -> requests.Response:
        return self._http_request(method="GET", url_suffix=f"/entry/download/{entry_id}", resp_type="content")

    def get_incident_entries(
        self,
        incident_id: str,
        from_date: int | None,
        max_results: int | None,
        categories: list[str] | None,
        tags: list[str] | None,
        tags_and_operator: bool,
    ) -> list[dict[str, Any]]:
        data = {
            "pageSize": max_results or 50,
            "fromTime": timestamp_to_datestring(from_date),
            "categories": categories,
            "tags": tags,
            "tagsAndOperator": tags_and_operator,
        }
        inv_with_entries = self._http_request(method="POST", url_suffix=f"/investigation/{incident_id}", json_data=data)
        return inv_with_entries.get("entries", [])

    def get_incident_fields(self) -> list[dict[str, Any]]:
        return self._http_request(method="GET", url_suffix="/incidentfields")

    def get_incident_types(self) -> list[dict[str, Any]]:
        return self._http_request(method="GET", url_suffix="/incidenttype")

    def get_modified_incidents(self, from_timestamp: int | None = None) -> list[str]:
        """Calls the /incidents/modified endpoint to get IDs of incidents modified in the given time range.

        Args:
            from_timestamp: Start of the time range (epoch seconds).

        Returns:
            A list of modified incident IDs.
        """
        params: dict[str, Any] = {"fromTimeStamp": from_timestamp}
        demisto.debug(f"get_modified_incidents: calling GET /public/v1/incidents/modified with {params=}")

        response = self._http_request(method="GET", url_suffix="/public/v1/incidents/modified", params=params)
        demisto.debug(f"get_modified_incidents: response type={type(response).__name__} value={response}")

        # The API returns a list of incident IDs.
        if isinstance(response, list):
            demisto.debug(f"get_modified_incidents: returning {len(response)} IDs: {response}")
            return response

        # If the response is not a list, log a debug message and return an empty list.
        demisto.debug("get_modified_incidents: unexpected non-list response, returning empty list")
        return []

    def update_incident(self, incident: dict[str, Any]) -> dict[str, Any]:
        return self._http_request(method="POST", url_suffix="/incident", json_data=incident)

    def close_incident(
        self, incident_id: str, incident_ver: int, close_reason: str | None, close_notes: str | None
    ) -> dict[str, Any]:
        return self._http_request(
            method="POST",
            url_suffix="/incident/close",
            json_data={"id": incident_id, "version": incident_ver, "closeNotes": close_notes, "closeReason": close_reason},
        )

    def add_incident_entry(self, incident_id: str | None, entry: dict[str, Any]):
        if entry.get("type") == 3:
            path_res = demisto.getFilePath(entry.get("id"))
            full_file_name = path_res.get("name")

            with open(path_res.get("path"), "rb") as file_to_send:
                self._http_request(
                    method="POST",
                    url_suffix=f"/entry/upload/{incident_id}",
                    files={"file": (full_file_name, file_to_send, "application/octet-stream")},
                )

        if not entry.get("note", False):
            demisto.debug(f'the entry has inv_id {incident_id}\nformat {entry.get("format")}\n contents {entry.get("contents")}')
            self._http_request(
                method="POST",
                url_suffix="/entry/formatted",
                json_data={"contents": entry.get("contents"), "format": entry.get("format"), "investigationId": incident_id},
            )
        else:
            demisto.debug(f'the entry has inv_id {incident_id}\ndata {entry.get("date")}\nmarkdown {entry.get("markdown")}')
            entry_format = entry.get("format") == "markdown"
            self._http_request(
                method="POST",
                url_suffix="/entry/note",
                json_data={"investigationId": incident_id, "data": entry.get("contents", "False"), "markdown": entry_format},
            )


""" HELPER FUNCTIONS """


def validate_and_prepare_basic_params(params: dict):
    """
    Validated and then prepares the API Key, API ID, and URL.

    Args:
        params (dict): The params dictionary.

    Return:
        str: the API key ID
        str: the API key secret
        str: the URL to the server with `xsoar` suffix if one is needed
    """
    api_key = params.get("credentials_api_key", {}).get("password") or params.get("apikey")
    api_key_id = params.get("credentials_api_key", {}).get("identifier")
    base_url = params.get("url", "")
    if not api_key:
        raise DemistoException("API Key must be provided.")

    # For cloud environments an 'xsoar' suffix must be added
    if api_key_id:
        base_url = base_url if base_url.endswith("/xsoar") else base_url + "/xsoar"

    return api_key_id, api_key, base_url


def arg_to_timestamp(arg: str, arg_name: str, required: bool = False):
    """Converts an XSOAR argument to a timestamp (seconds from epoch)

    This function is used to quickly validate an argument provided to XSOAR
    via ``demisto.args()`` into an ``int`` containing a timestamp (seconds
    since epoch). It will throw a ValueError if the input is invalid.
    If the input is None, it will throw a ValueError if required is ``True``,
    or ``None`` if required is ``False.

    :type arg: ``Any``
    :param arg: argument to convert

    :type arg_name: ``str``
    :param arg_name: argument name

    :type required: ``bool``
    :param required:
        throws exception if ``True`` and argument provided is None

    :return:
        returns an ``int`` containing a timestamp (seconds from epoch) if conversion works
        returns ``-1`` if arg is ``None`` and required is set to ``False``
        otherwise throws an Exception
    :rtype: ``Optional[int]``
    """

    if arg is None:
        if required is True:
            raise ValueError(f'Missing "{arg_name}"')
        return -1

    if isinstance(arg, str) and arg.isdigit():
        # timestamp is a str containing digits - we just convert it to int
        return int(arg)
    if isinstance(arg, str):
        # we use dateparser to handle strings either in ISO8601 format, or
        # relative time stamps.
        # For example: format 2019-10-23T00:00:00 or "3 days", etc
        date = dateparser.parse(arg, settings={"TIMEZONE": "UTC"})
        if date is None:
            # if d is None it means dateparser failed to parse it
            raise ValueError(f"Invalid date: {arg_name}")

        return int(date.timestamp())
    if isinstance(arg, int | float):
        # Convert to int if the input is a float
        return int(arg)
    raise ValueError(f'Invalid date: "{arg_name}"')


def test_module(client: Client, first_fetch_time: str) -> str:
    """Tests API connectivity and authentication

    Returning 'ok' indicates that the integration works like it is supposed to.
    Connection to the service is successful.
    Raises exceptions if something goes wrong.

    :type client: ``Client``
    :param client: XSOAR client to use

    :type first_fetch_time: ``str``
    :param first_fetch_time: First fetch time parameter value.

    :return: 'ok' if test passed, anything else will fail the test.
    :rtype: ``str``
    """

    try:
        client.search_incidents(max_results=1, start_time=first_fetch_time, query=None)
        return "ok"
    except DemistoException as e:
        if "Forbidden" in str(e):
            return "Authorization Error: make sure API Key is correctly set"
        else:
            raise e


def fetch_incidents(
    client: Client,
    max_results: int,
    last_run: dict[str, Union[str, int, list[str]]],
    last_fetch: Union[str, int],
    first_fetch_time: Union[int, str],
    query: str | None,
    mirror_direction: str,
    mirror_tag: list[str],
    mirror_playbook_id: bool = False,
    fetch_incident_history: bool = False,
) -> tuple[dict[str, Union[list[dict[Any, Any]], str, Any]], list[dict[str, Any]]]:
    """This function retrieves new incidents every interval (default is 1 minute).

    :type client: ``Client``
    :param client: XSOAR client to use

    :type max_results: ``int``
    :param max_results: Maximum numbers of incidents per fetch

    :type last_run: ``Dict[str, Union[str, int, List[str]]],``
    :param last_run:
        A dict with a key containing the latest incident created time we got
        from last fetch

    :type first_fetch_time: ``Optional[int, str]``
    :param first_fetch_time:
        If last_run is None (first time we are fetching), it contains
        the timestamp in milliseconds on when to start fetching incidents

    :type query: ``Optional[str]``
    :param query:
        query to fetch the relevant incidents

    :type mirror_direction: ``str``
    :param mirror_direction:
        Mirror direction for the fetched incidents

    :type mirror_playbook_id: `bool`
    :param mirror_playbook_id:
        When set to false, mirrored incidents will have a blank playbookId value,
         causing the receiving machine to run the default playbook of the incident type.

    :type fetch_incident_history: `bool`
    :param fetch_incident_history:
        When set to True, all incidents fetched will be added to a context data list
        which will be used in  get_remote_data_command

    :type mirror_tag: ``List[str]``
    :param mirror_tag:
        The tags that you will mirror out of the incident.

    :return:
        A tuple containing two elements:
            next_run (``Dict[str, str]``): Contains the timestamp that will be
                    used in ``last_run`` on the next fetch.
            incidents (``List[dict]``): List of incidents that will be created in XSOAR

    :rtype: ``Tuple[Dict[str, str], List[dict]]``
    """
    demisto.debug(f"last run is: {last_run}")
    last_fetched_incidents: list = last_run.get("last_fetched_incidents", [])  # type: ignore
    if not last_fetch:
        last_fetch = first_fetch_time  # type: ignore
    else:
        demisto.debug(
            "Trying to convert the last_fetch to int, and convert it to date string if succeed. "
            "This is for preventing backward compatibility breakage."
        )
        try:
            last_fetch = int(last_fetch)
            last_fetch = datetime.fromtimestamp(last_fetch).strftime(XSOAR_DATE_FORMAT)
        except Exception:
            pass

    latest_created_time = dateparser.parse(last_fetch)  # type: ignore[arg-type]
    incidents_result: list[dict[str, Any]] = []
    if query:
        query += f' and created:>="{last_fetch}"'
    else:
        query = f'created:>="{last_fetch}"'

    demisto.debug(f"XSOAR Mirroring: Fetching incidents since last fetch: {last_fetch}")

    incidents, last_fetched_incidents, last_fetched_incident_time = get_and_dedup_incidents(
        client, last_fetched_incidents, query, max_results, last_fetch
    )
    demisto.debug(f"last_fetched_incident_time: {last_fetched_incident_time}")
    if fetch_incident_history:
        integration_context = get_integration_context()
        incident_mirror_reset: dict = {incident["id"]: True for incident in incidents}
        if incident_mirror_reset:
            if isinstance(integration_context.get(MIRROR_RESET), dict):
                demisto.debug(
                    f"Adding incidents id: {incident_mirror_reset} "
                    f"to integration context: {integration_context.get(MIRROR_RESET)}"
                )
                integration_context[MIRROR_RESET].update(incident_mirror_reset)
            else:
                demisto.debug(f"Set integration context to: {incident_mirror_reset}")
                integration_context[MIRROR_RESET] = incident_mirror_reset
            set_to_integration_context_with_retries(context=integration_context)

    for incident in incidents:
        incident_result: dict[str, Any] = {}
        incident_result["dbotMirrorDirection"] = MIRROR_DIRECTION.get(mirror_direction)  # type: ignore
        incident["dbotMirrorInstance"] = demisto.integrationInstance()
        incident_result["dbotMirrorTags"] = mirror_tag if mirror_tag else None  # type: ignore
        incident_result["dbotMirrorId"] = incident["id"]

        if mirror_playbook_id:
            fields = FIELDS_TO_COPY_FROM_REMOTE_INCIDENT
        else:
            fields = [field for field in FIELDS_TO_COPY_FROM_REMOTE_INCIDENT if field != "playbookId"]

        for key, value in incident.items():
            if key in fields:
                incident_result[key] = value

        incident_result["rawJSON"] = json.dumps(incident)

        file_attachments = []
        # When demisto.command() == 'test-module' we can't write files since we are not running in a playground.
        if (
            demisto.command() != "test-module"
            and incident.get("attachment")
            and len(incident.get("attachment", [])) > 0
            and incident.get("investigationId")
        ):
            entries = client.get_incident_entries(
                incident_id=incident["investigationId"],  # type: ignore
                from_date=0,
                max_results=10,
                categories=["attachments"],
                tags=None,
                tags_and_operator=False,
            )

            for entry in entries:
                if "file" in entry and entry.get("file"):
                    file_entry_content = client.get_file_entry(entry.get("id"))  # type: ignore
                    file_result = fileResult(entry["file"], file_entry_content)
                    if any(attachment.get("name") == entry["file"] for attachment in incident.get("attachment", [])):
                        if file_result["Type"] == EntryType.ERROR:
                            raise Exception(f"Error getting attachment: {file_result.get('Contents', '')!s}")

                        file_attachments.append({"path": file_result.get("FileID", ""), "name": file_result.get("File", "")})

        incident_result["attachment"] = file_attachments
        incidents_result.append(incident_result)
        incident_created_time = dateparser.parse(incident.get("created"), settings={"TIMEZONE": "Z"})  # type: ignore[arg-type]
        # Update last run and add incident if the incident is newer than last fetch
        if incident_created_time > latest_created_time:  # type: ignore[operator]
            latest_created_time = incident_created_time

    # Save the next_run as a dict with the last_fetch key to be stored
    next_run = {
        "last_fetch": (latest_created_time)  # type: ignore[operator]
        .strftime(XSOAR_DATE_FORMAT),  # type: ignore[union-attr,operator]
        "last_fetched_incidents": last_fetched_incidents,
    }
    demisto.debug(f"XSOAR Mirroring: Setting next run to: {next_run}")
    return next_run, incidents_result


def search_incidents_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """xsoar-search-incidents command: Search XSOAR incidents

    :type client: ``Client``
    :param Client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['query']`` query to search incidents
        ``args['start_time']``  start time as ISO8601 date or seconds since epoch
        ``args['max_results']`` maximum number of results to return
        ``args['columns']`` which columns to display in the table

    :return:
        A ``CommandResults`` object that is then passed to ``return_results``,
        that contains incidents

    :rtype: ``CommandResults``
    """

    query = args.get("query")
    start_date = dateparser.parse(args.get("start_time", "3 days")).strftime(XSOAR_DATE_FORMAT)  # type: ignore[union-attr]
    max_results = arg_to_number(arg=args.get("max_results"), arg_name="max_results", required=False)
    incidents = client.search_incidents(query=query, start_time=start_date, max_results=max_results)
    if not incidents:
        incidents = []

    columns = args.get("columns")
    if columns != "all":
        columns = argToList(columns)
        human_readable = tableToMarkdown("Search Results:", incidents, columns)
    else:
        human_readable = tableToMarkdown("Search Results:", incidents)

    return CommandResults(
        readable_output=human_readable, outputs_prefix="XSOAR.Incident", outputs_key_field="id", outputs=incidents
    )


def get_incident_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """xsoar-get-incident command: Returns an incident and all entries with given categories and tags

    :type client: ``Client``
    :param Client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['id']`` incident ID to return
        ``args['from_date']`` only return entries after last update
        ``args['categories']`` only return entries with given categories
        ``args['tags']`` only return entries with given tags

    :return:
        A ``CommandResults`` object that is then passed to ``return_results``,
        that contains an alert

    :rtype: ``CommandResults``
    """

    incident_id = args.get("id", None)
    if not incident_id:
        raise ValueError("id not specified")

    from_date_arg = args.get("from_date", "3 days")
    from_date = arg_to_timestamp(arg=from_date_arg, arg_name="from_date", required=False)
    max_results = arg_to_number(arg=args.get("max_results"), arg_name="max_results", required=False)
    categories = args.get("categories", None)
    if categories:
        categories = categories.split(",")

    tags = args.get("tags", None)
    if tags:
        tags = tags.split(",")

    incident = client.get_incident(incident_id=incident_id)
    incident_title = incident.get("name", incident_id)

    readable_output = tableToMarkdown(f"Incident {incident_title}", incident)

    entries = client.get_incident_entries(
        incident_id=incident_id,
        from_date=from_date * 1000,
        max_results=max_results,
        categories=categories,
        tags=tags,
        tags_and_operator=True,
    )

    readable_output += "\n\n" + tableToMarkdown(
        f"Last entries since {timestamp_to_datestring(from_date * 1000)}", entries, removeNull=True
    )

    return CommandResults(
        readable_output=readable_output, outputs_prefix="XSOAR.Incident", outputs_key_field="incident_id", outputs=incident
    )


def get_mapping_fields_command(client: Client) -> GetMappingFieldsResponse:
    """get-mapping-fields command: Returns the list of fields for an incident type

    :type client: ``Client``
    :param Client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['type']`` incident type to retrieve fields for

    :return:
        A ``Dict[str, Any]`` object with keys as field names and description as values

    :rtype: ``Dict[str, Any]``
    """
    all_mappings = GetMappingFieldsResponse()
    incident_fields: list[dict] = client.get_incident_fields()
    types = client.get_incident_types()
    for incident_type_obj in types:
        custom_fields = {}
        incident_type_name: str = incident_type_obj.get("name")  # type: ignore
        incident_type_scheme = SchemeTypeMapping(type_name=incident_type_name)
        demisto.debug(f'Collecting incident mapping for incident type - "{incident_type_name}"')

        for field in incident_fields:
            if (
                field.get("group") == 0
                and field.get("cliName") is not None
                and (
                    field.get("associatedToAll")
                    or incident_type_name  # type: ignore
                    in field.get("associatedTypes")
                )
            ):  # type: ignore
                if field.get("content") or not field.get("system"):
                    custom_fields[field.get("cliName")] = f"{field.get('name')} - {field.get('type')}"
                    continue
                incident_type_scheme.add_field(
                    name=field.get("cliName"), description=f"{field.get('name')} - {field.get('type')}"
                )
        if custom_fields:
            incident_type_scheme.add_field(name="CustomFields", description=custom_fields)
        all_mappings.add_scheme_type(incident_type_scheme)

    default_scheme = SchemeTypeMapping(type_name="Default Mapping")
    demisto.debug("Collecting the default incident scheme")
    custom_fields = {}
    for field in incident_fields:
        if field.get("group") == 0 and field.get("associatedToAll"):
            if field.get("content") or not field.get("system"):
                custom_fields[field.get("cliName")] = f"{field.get('name')} - {field.get('type')}"
                continue
            default_scheme.add_field(name=field.get("cliName"), description=f"{field.get('name')} - {field.get('type')}")

    if custom_fields:
        default_scheme.add_field(name="CustomFields", description=custom_fields)
    all_mappings.add_scheme_type(default_scheme)
    return all_mappings


def demisto_debug(msg):
    if demisto.params().get("debug_mode"):
        demisto.info(msg)
    else:
        demisto.debug(msg)


def get_remote_data_command(client: Client, args: dict[str, Any], params: dict[str, Any]) -> GetRemoteDataResponse:
    """get-remote-data command: Returns an updated incident and entries

    :type client: ``Client``
    :param Client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['id']`` incident id to retrieve
        ``args['lastUpdate']`` when was the last time we retrieved data

    :return:
        A ``List[Dict[str, Any]]`` first entry is the incident (which can be completely empty) and others are the new entries

    :rtype: ``List[Dict[str, Any]]``
    """
    demisto_debug(f"##### get-remote-data args: {json.dumps(args, indent=4)}")
    incident = None
    try:
        args["lastUpdate"] = arg_to_timestamp(
            arg=args.get("lastUpdate"),  # type: ignore
            arg_name="lastUpdate",
            required=True,
        )
        remote_args = GetRemoteDataArgs(args)
        demisto_debug(f"Getting update for remote [{remote_args.remote_incident_id}]")

        categories = argToList(params.get("categories", None))
        tags = argToList(params.get("tags", None))

        incident = client.get_incident(incident_id=remote_args.remote_incident_id)  # type: ignore
        # If incident was modified before we last updated, no need to return it
        modified = arg_to_timestamp(
            arg=incident.get("modified"),  # type: ignore
            arg_name="modified",
            required=False,
        )
        occurred = arg_to_timestamp(
            arg=incident.get("occurred"),  # type: ignore
            arg_name="occurred",
            required=False,
        )

        if datetime.fromtimestamp(modified) - datetime.fromtimestamp(occurred) < timedelta(minutes=1) and datetime.fromtimestamp(
            remote_args.last_update
        ) - datetime.fromtimestamp(modified) < timedelta(minutes=1):
            remote_args.last_update = occurred + 1
            # in case new entries created less than a minute after incident creation

        demisto_debug(f"tags: {tags}")
        demisto_debug(f"categories: {categories}")
        integration_context = get_integration_context()
        XSOARMirror_mirror_reset: dict = json.loads(integration_context.get(MIRROR_RESET, "{}"))
        is_incident_update_after_reset = False
        if XSOARMirror_mirror_reset:
            is_incident_update_after_reset = XSOARMirror_mirror_reset.get(remote_args.remote_incident_id, None)
        from_date = 0 if is_incident_update_after_reset else remote_args.last_update * 1000
        demisto.debug(f"Requesting update for incident id {remote_args.remote_incident_id} from date: {from_date}")
        entries = client.get_incident_entries(
            incident_id=remote_args.remote_incident_id,  # type: ignore
            from_date=from_date,
            max_results=100,
            categories=categories,
            tags=tags,
            tags_and_operator=True,
        )
        if is_incident_update_after_reset:
            del XSOARMirror_mirror_reset[remote_args.remote_incident_id]
            integration_context[MIRROR_RESET] = XSOARMirror_mirror_reset
            set_to_integration_context_with_retries(context=integration_context)
            demisto.debug(f"Removed incident id: {remote_args.remote_incident_id} from XSOARMirror_mirror_reset\
                context data list.")

        formatted_entries = []
        # file_attachments = []

        if entries:
            demisto.debug(f"Got entries: {entries} for incident id {remote_args.remote_incident_id}")
            for entry in entries:
                demisto.debug(f"Got entry {entry}")
                if "file" in entry and entry.get("file"):
                    file_entry_content = client.get_file_entry(entry.get("id"))  # type: ignore
                    file_result = fileResult(entry["file"], file_entry_content)

                    formatted_entries.append(file_result)
                else:
                    formatted_entries.append(
                        {
                            "Type": entry.get("type"),
                            "Category": entry.get("category"),
                            "Contents": entry.get("contents"),
                            "ContentsFormat": entry.get("format"),
                            "Tags": entry.get("tags"),  # the list of tags to add to the entry
                            "Note": entry.get("note"),  # boolean, True for Note, False otherwise
                        }
                    )

        # Handle if the incident closed remotely
        if incident.get("status") == IncidentStatus.DONE:
            demisto.debug("incident was closed remotely, adding note")
            formatted_entries.append(
                {
                    "Type": EntryType.NOTE,
                    "Contents": {
                        "dbotIncidentClose": True,
                        "closeReason": incident.get("closeReason"),
                        "closeNotes": incident.get("closeNotes"),
                    },
                    "ContentsFormat": EntryFormat.JSON,
                }
            )

        incident["in_mirror_error"] = ""

        if remote_args.last_update >= modified and not formatted_entries:
            demisto.debug(f"Nothing new in the incident, incident id {remote_args.remote_incident_id}")
            incident = {}  # this empties out the incident, which will result in not updating the local one

        incident["dbotMirrorInstance"] = demisto.integrationInstance()
        incident["id"] = remote_args.remote_incident_id
        # incident['attachment'] = file_attachments
        mirror_data = GetRemoteDataResponse(mirrored_object=incident, entries=formatted_entries)
        return mirror_data

    except Exception as e:
        demisto.error(f"Error in XSOAR incoming mirror for incident {args['id']} \nError message: {e!s}")
        incident = {"id": args["id"], "in_mirror_error": str(e)}

        return GetRemoteDataResponse(mirrored_object=incident, entries=[])


def update_remote_system_command(client: Client, args: dict[str, Any], mirror_tags: set[str]) -> str:
    """update-remote-system command: pushes local changes to the remote system

    :type client: ``Client``
    :param client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['data']`` the data to send to the remote system
        ``args['entries']`` the entries to send to the remote system
        ``args['incidentChanged']`` boolean telling us if the local incident indeed changed or not
        ``args['remoteId']`` the remote incident id

    :type mirror_tags: ``Optional[str]``
    :param mirror_tags:
        The tag that you will mirror out of the incident.

    :return:
        ``str`` containing the remote incident id - really important if the incident is newly created remotely

    :rtype: ``str``
    """
    parsed_args = UpdateRemoteSystemArgs(args)
    if parsed_args.delta:
        demisto.debug(f"Got the following delta keys {list(parsed_args.delta.keys())!s}")

    demisto.debug(f"Sending incident with remote ID [{parsed_args.remote_incident_id}] to remote system\n")

    new_incident_id: str = parsed_args.remote_incident_id  # type: ignore
    updated_incident = {}
    if not parsed_args.remote_incident_id or parsed_args.incident_changed:
        if parsed_args.remote_incident_id:
            # First, get the incident as we need the version
            old_incident = client.get_incident(incident_id=parsed_args.remote_incident_id)
            for changed_key in parsed_args.delta:
                old_incident[changed_key] = parsed_args.delta[changed_key]  # type: ignore
                if changed_key in old_incident.get("CustomFields", {}):
                    old_incident["CustomFields"][changed_key] = parsed_args.delta[changed_key]

            parsed_args.data = old_incident

        else:
            parsed_args.data["createInvestigation"] = True
            # This is used to identify the custom field, so that we will know the source of the incident
            # and will not mirror it in afterwards
            parsed_args.data["CustomFields"] = {"frompong": "true"}

        updated_incident = client.update_incident(incident=parsed_args.data)
        new_incident_id = updated_incident["id"]
        demisto.debug(f"Got back ID [{new_incident_id}]")

    else:
        demisto.debug(
            f"Skipping updating remote incident fields [{parsed_args.remote_incident_id}] as it is not new nor changed."
        )

    if parsed_args.entries:
        for entry in parsed_args.entries:
            demisto.info(f"this is the tag {entry.get('tags', [])}")
            if mirror_tags.intersection(set(entry.get("tags", []))):
                demisto.debug(f'Sending entry {entry.get("id")}')
                client.add_incident_entry(incident_id=new_incident_id, entry=entry)

    # Close incident if relevant
    if updated_incident and parsed_args.inc_status == IncidentStatus.DONE:
        demisto.debug(f"Closing remote incident {new_incident_id}")
        client.close_incident(
            new_incident_id,
            updated_incident.get("version"),  # type: ignore
            parsed_args.data.get("closeReason"),
            parsed_args.data.get("closeNotes"),
        )

    return new_incident_id


def get_modified_remote_data_command(client: Client, args: dict[str, Any]) -> GetModifiedRemoteDataResponse:
    """get-modified-remote-data command: Returns a list of incident IDs that were modified since the last check.

    This command enables incremental mirroring by allowing the XSOAR engine to fetch only
    the incidents that changed since the last sync, rather than performing a full blind sync.

    :type client: ``Client``
    :param client: XSOAR client to use

    :type args: ``Dict[str, Any]``
    :param args:
        all command arguments, usually passed from ``demisto.args()``.
        ``args['lastUpdate']`` ISO8601 timestamp of the last successful sync

    :return:
        A ``GetModifiedRemoteDataResponse`` containing the list of modified incident IDs.

    :rtype: ``GetModifiedRemoteDataResponse``
    """
    remote_args = GetModifiedRemoteDataArgs(args)

    # --- Circuit-breaker: skip the endpoint for the rest of the day if it was found unsupported ---
    today_utc: str = datetime.now(tz=UTC).strftime("%Y-%m-%d")
    integration_context = get_integration_context()
    unsupported_date: str = integration_context.get(MODIFIED_ENDPOINT_UNSUPPORTED_DATE_KEY, "")
    if unsupported_date == today_utc:
        cached_error_msg = (
            "The remote XSOAR machine does not support the /public/v1/incidents/modified endpoint. "
            "This endpoint is only available on XSOAR 8.15.0 and later. "
            f"The endpoint was found unsupported on {unsupported_date} (UTC) and will be retried tomorrow."
        )
        demisto.error(f"get-modified-remote-data: {cached_error_msg}")
        demisto.results(
            {
                "Type": EntryType.ERROR,
                "ContentsFormat": EntryFormat.TEXT,
                "Contents": cached_error_msg,
            }
        )
        sys.exit(0)

    # last_update is an ISO8601 string (e.g. '2026-01-10T00:00:00.123456789Z'); fall back to now minus 1 minute if empty.
    if not remote_args.last_update:
        from_timestamp = int((datetime.now(tz=UTC) - timedelta(minutes=1)).timestamp())
        demisto.debug(
            f"get-modified-remote-data: last_update was empty, defaulting to now minus 1 minute (epoch: {from_timestamp})"
        )
    else:
        parsed = dateparser.parse(str(remote_args.last_update), settings={"TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True})
        if not parsed:
            raise DemistoException(f"Failed to parse last_update={remote_args.last_update!r} as ISO8601")
        from_timestamp = int(parsed.timestamp())

    demisto.debug(f"get-modified-remote-data: fetching incidents modified since epoch {from_timestamp}")

    try:
        modified_incident_ids = client.get_modified_incidents(from_timestamp=from_timestamp)
    except DemistoException as e:
        status_code = e.res.status_code if e.res is not None else None
        demisto.error(f"get-modified-remote-data: encountered an error while fetching modified incidents: {e}")
        if e.res is not None and status_code in (303, 404):
            error_msg = (
                f"The remote XSOAR machine does not support the /public/v1/incidents/modified endpoint "
                f"(HTTP {status_code=}). This endpoint is only available on XSOAR 8.15.0 and later. Original error: {e}"
            )
            demisto.error(f"get-modified-remote-data: {error_msg}")
            # Persist the flag so subsequent cycles skip this endpoint for the rest of today (UTC).
            integration_context[MODIFIED_ENDPOINT_UNSUPPORTED_DATE_KEY] = today_utc
            set_to_integration_context_with_retries(context=integration_context)
            demisto.debug(f"get-modified-remote-data: stored unsupported-endpoint flag for {today_utc} in integration context.")
            demisto.results(
                {
                    "Type": EntryType.ERROR,
                    "ContentsFormat": EntryFormat.TEXT,
                    "Contents": error_msg,
                }
            )
            sys.exit(0)
        raise

    # If the call succeeded and a stale unsupported-flag exists in the integration context
    # (meaning the remote machine was upgraded), clear it so the endpoint is no longer suppressed.
    if integration_context.get(MODIFIED_ENDPOINT_UNSUPPORTED_DATE_KEY):
        del integration_context[MODIFIED_ENDPOINT_UNSUPPORTED_DATE_KEY]
        set_to_integration_context_with_retries(context=integration_context)
        demisto.debug(
            "get-modified-remote-data: cleared unsupported-endpoint flag from integration context after successful call."
        )

    demisto.debug(f"get-modified-remote-data: found {len(modified_incident_ids)} modified incident(s): {modified_incident_ids}")
    return GetModifiedRemoteDataResponse(modified_incident_ids)


def get_and_dedup_incidents(
    client: Client, last_fetched_incidents: list[Any], query: str, max_results: int, last_fetch: Union[str, int]
) -> tuple[list[dict], list[dict], Optional[datetime]]:
    """get incidents and dedup the incidents response.

    Cases:
    1.  Empty incidents list (no new incidents received from API response).
        Return empty list of incidents and the unchanged the list of 'last_fetched_incidents'.

    2.  The response includes incidents from the previous fetch cycle, with the same timestamp.
        Fetch incidents until the number of incidents is equal to the requested max fetch_limit.
        Add the list of fetched incident IDs to the current 'last_fetched_incidents' from last run,
        return a list of new incidents, and updated list of 'last_fetched_incidents'.

    3.  Most recent incident has a later timestamp than other incidents in the response.
        Return a list of new incidents and a list of 'new_ids' containing only the IDs of
        incidents with identical latest times for next run.

    Args:
        incidents (list[dict]): List of incidents from the current fetch response.
        last_fetched_incidents (list[dict]): List of IDs of incidents from last fetch cycle.

    Returns:
        tuple[list[dict], list[str]: The list of dedup incidents and ID list of incidents of current fetch.
    """
    last_fetched_incident_time = dateparser.parse(str(last_fetch))

    new_incidents: list = []
    page = 0
    while len(new_incidents) < max_results:
        incidents = client.search_incidents(
            query=query,
            max_results=max_results,
            start_time=last_fetch,
            field="created",
            page=page,
        )
        demisto.debug(f"incidents: {incidents}")
        # Case 1: Empty response.
        if len(incidents) == 0:
            break
        for incident in incidents:
            if len(new_incidents) >= max_results:
                break
            incident_id = incident.get("id")
            created = incident.get("created", last_fetched_incident_time)
            demisto.debug(f"before incident_creation_time with timezone: {created}")
            incident_creation_time = dateparser.parse(
                incident.get("created", last_fetched_incident_time), settings={"TIMEZONE": "Z"}
            )
            demisto.debug(f"{incident_id} incident_creation_time: {incident_creation_time}")
            if incident_id not in last_fetched_incidents:
                # Case 3: The last fetched incident with the different timestamp then the previous incident.
                if last_fetched_incident_time and incident_creation_time and last_fetched_incident_time < incident_creation_time:
                    demisto.debug(f"XSOAR Mirroring: reset the last_fetched_incidents list with id {incident_id}")
                    last_fetched_incidents = [incident_id]
                # Case 2: The last fetched incident with the same timestamp as the previous incident.
                else:
                    demisto.debug(f"XSOAR Mirroring: attached id {incident_id} to the last_fetched_incidents list")
                    last_fetched_incidents.append(incident_id)
                new_incidents.append(incident)
                last_fetched_incident_time = incident_creation_time
        page += 1
    return new_incidents, last_fetched_incidents, last_fetched_incident_time


def main() -> None:  # pragma: no cover
    params = demisto.params()

    api_key_id, api_key, base_url = validate_and_prepare_basic_params(params)
    verify_certificate = not demisto.params().get("insecure", False)

    # How much time before the first fetch to retrieve incidents
    first_fetch_time = arg_to_datetime(demisto.params().get("first_fetch", "3 days")).strftime(XSOAR_DATE_FORMAT)  # type: ignore

    proxy = demisto.params().get("proxy", False)
    demisto.debug(f"Command being called is {demisto.command()}")
    mirror_tags = set(demisto.params().get("mirror_tag", "").split(",")) if demisto.params().get("mirror_tag") else set()

    query = demisto.params().get("query", "") or ""
    disable_from_same_integration = demisto.params().get("disable_from_same_integration")
    if disable_from_same_integration:
        query += ' -sourceBrand:"XSOAR Mirroring"'

    max_results = arg_to_number(arg=demisto.params().get("max_fetch"), arg_name="max_fetch")
    if not max_results or max_results > MAX_INCIDENTS_TO_FETCH:
        max_results = MAX_INCIDENTS_TO_FETCH

    try:
        headers = {
            "Authorization": api_key,
            "x-xdr-auth-id": api_key_id,
            "Content-Type": "application/json",
        }
        client = Client(base_url=base_url, verify=verify_certificate, headers=headers, proxy=proxy)

        if demisto.command() == "test-module":
            if demisto.params().get("isFetch"):
                fetch_incidents(
                    client=client,
                    max_results=max_results,
                    last_run=demisto.getLastRun(),
                    last_fetch=demisto.getLastRun().get("last_fetch"),
                    first_fetch_time=first_fetch_time,
                    query=query,
                    mirror_direction=demisto.params().get("mirror_direction"),
                    mirror_tag=list(mirror_tags),
                    mirror_playbook_id=demisto.params().get("mirror_playbook_id", True),
                    fetch_incident_history=demisto.params().get("fetch_incident_history", False),
                )

            return_results(test_module(client, first_fetch_time))

        elif demisto.command() == "fetch-incidents":
            next_run, incidents = fetch_incidents(
                client=client,
                max_results=max_results,
                last_run=demisto.getLastRun(),
                last_fetch=demisto.getLastRun().get("last_fetch"),
                first_fetch_time=first_fetch_time,
                query=query,
                mirror_direction=demisto.params().get("mirror_direction"),
                mirror_tag=list(mirror_tags),
                mirror_playbook_id=demisto.params().get("mirror_playbook_id", True),
                fetch_incident_history=demisto.params().get("fetch_incident_history", False),
            )
            demisto.setLastRun(next_run)
            demisto.incidents(incidents)

        elif demisto.command() == "xsoar-search-incidents":
            return_results(search_incidents_command(client, demisto.args()))

        elif demisto.command() == "xsoar-get-incident":
            return_results(get_incident_command(client, demisto.args()))

        elif demisto.command() == "get-mapping-fields":
            return_results(get_mapping_fields_command(client))

        elif demisto.command() == "get-remote-data":
            return_results(get_remote_data_command(client, demisto.args(), demisto.params()))

        elif demisto.command() == "update-remote-system":
            return_results(update_remote_system_command(client, demisto.args(), mirror_tags))

        elif demisto.command() == "get-modified-remote-data":
            return_results(get_modified_remote_data_command(client, demisto.args()))

        else:
            raise NotImplementedError("Command not implemented")

    except NotImplementedError:
        raise
    except Exception as e:
        return_error(f"Failed to execute {demisto.command()} command.\nError:\n{e!s}")


""" ENTRY POINT """


if __name__ in ("__main__", "__builtin__", "builtins"):
    main()