Doppel

Doppel is a Modern Digital Risk Protection Solution, that detects the phishing and brand cyber attacks on the emerging channels. Doppel scans millions of channels online which includes, social media, domains, paid ads, dark web, emerging channels, etc. Doppel can identify the malicious content and cyber threats, and enables their customers to take down the digital risks proactively. The Cortex XSOAR pack for Doppel mirrors the alerts created by Doppel as Cortex XSOAR incidents. The pack also contains the commands to perform different operations on Doppel alerts.

Data Enrichment & Threat Intelligence · Doppel

Details

IDDoppel
ProviderDoppel
CategoryData Enrichment & Threat Intelligence
From Version6.10.0
Docker Imagedemisto/python3:3.12.13.10404775

README

Overview

Doppel is a Modern Digital Risk Protection Solution, that detects the phishing and brand cyber attacks on the emerging channels. Doppel scans millions of channels online which includes, social media, domains, paid ads, dark web, emerging channels, etc. Doppel can identify the malicious content and cyber threats, and enables their customers to take down the digital risks proactively. The Cortex XSOAR pack for Doppel mirrors the alerts created by Doppel as Cortex XSOAR incidents. The pack also contains the commands to perform different operations on Doppel alerts.

Configure Doppel on Cortex XSOAR

  1. Navigate to Settings & Info > Settings > Integrations > Instances.
  2. Search for Doppel.
  3. Click Add instance to create and configure a new integration instance.
Parameter Description Required
Doppel Tenant URL The Doppel server URL that will be used for calling the APIs. True
API Key The API Key to use for connection with Doppel. True
User API Key The User API Key (Optional) to use for connection with Doppel. False
Organization Code Optional organization identifier used when your Doppel environment is organization-scoped. If required by the Doppel API, include the organization code provided by your Doppel administrator. False
Trust Any Certificate (not secure) When checked, SSL certificate verification is disabled. Use this only when the Doppel endpoint uses a self-signed or untrusted certificate. False
Use System Proxy Settings When checked, the integration uses the system proxy defined in the XSOAR engine configuration (d1.conf). This is required if the engine routes outbound traffic through a local or organizational proxy. False
Fetch incidents   False
Incidents Fetch Interval The interval specifies the cadence with which the Doppel alerts be fetched. True
Incident type   False
Mirror Direction Choose the direction to mirror the incident: None (No mirroring), Incoming (from Doppel to Cortex XSOAR), Outgoing (from Cortex XSOAR to Doppel), Incoming and Outgoing (from/to Cortex XSOAR and Doppel). Cortex XSOAR only parameter. False
First fetch First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days). False
Fetch incidents timeout The time limit in seconds for fetch incidents to run. Leave this empty to cancel the timeout limit. False
Number of incidents for each fetch. Due to API limitations, the maximum is 100. False
Trust any certificate (not secure)   False
Use system proxy settings   False
  1. Click Test to validate the URLs, token, and connection.

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.

doppel-get-alert


Retrieves the alert details by ID or entity. Must include either ID or entity.

Base Command

doppel-get-alert

Input

Argument Name Description Required
id The ID of the alert to retrieve details for. Optional
entity The alerted entity to retrieve details for. Optional

Context Output

Path Type Description
Doppel.Alert.id String The unique identifier of the alert (e.g., TET-1953421).
Doppel.Alert.entity String The URL or profile link related to the alert.
Doppel.Alert.brand String The brand associated with the alert.
Doppel.Alert.queue_state String The current state of the alert in the queue.
Doppel.Alert.entity_state String The current state of the alert entity (e.g., active, inactive).
Doppel.Alert.severity String The severity level of the alert (e.g., low, medium, high).
Doppel.Alert.product String The product category associated with the alert (e.g., social media).
Doppel.Alert.platform String The platform on which the alert was generated (e.g., Bluesky).
Doppel.Alert.source String The source from which the alert was generated (e.g., Analyst Upload).
Doppel.Alert.notes Unknown Additional notes related to the alert, if any.
Doppel.Alert.created_at Date The timestamp when the alert was created.
Doppel.Alert.doppel_link String The link to the alert on the Doppel platform.
Doppel.Alert.entity_content Unknown Additional content related to the alert entity.
Doppel.Alert.audit_logs.timestamp Date Timestamp when the audit log entry was created.
Doppel.Alert.audit_logs.type String The type of audit log entry (e.g., alert_create).
Doppel.Alert.audit_logs.value String The value of the audit log entry (e.g., needs_review).
Doppel.Alert.audit_logs.changed_by String The user who made the change, if available.
Doppel.Alert.audit_logs.metadata Unknown Additional metadata related to the audit log.
Doppel.Alert.tags Unknown Tags associated with the alert.
Doppel.Alert.uploaded_by String The user or source who uploaded the alert (e.g., Doppel).

Command example

!doppel-get-alert id="TST-31"

Context Example

{
  "Doppel": {
    "Alert": {
      "id": "TST-31",
      "entity": "http://dummyrul.com",
      "brand": "test_brand",
      "queue_state": "doppel_review",
      "entity_state": "active",
      "severity": "medium",
      "product": "domains",
      "platform": "domains",
      "source": "Analyst Upload",
      "notes": null,
      "created_at": "2024-11-27T06:51:50.357664",
      "doppel_link": "https://app.doppel.com/alerts/TST-31222",
      "entity_content": {
        "root_domain": {
          "domain": "dummyrul.com",
          "registrar": null,
          "ip_address": null,
          "country_code": null,
          "hosting_provider": null,
          "contact_email": null
        }
      },
      "audit_logs": [
        {
          "timestamp": "2024-11-27T06:51:50.357664",
          "type": "alert_create",
          "value": "needs_review",
          "changed_by": "currentuser@doppel.com",
          "metadata": {}
        }
      ],
      "tags": [],
      "uploaded_by": "currentuser@doppel.com"
    }
  }
}

Human Readable Output

Alert Details

ID Entity Brand Queue State Entity State Severity Product Platform Source Created At Doppel Link Uploaded By
TST-31 http://dummyrul.com test_brand doppel_review active medium domains domains Analyst Upload 2024-11-27T06:51:50.357664 Doppel Link currentuser@doppel.com

doppel-create-alert


Creates an alert for a specified entity. This command requires the entity to be provided in the arguments.

Base Command

doppel-create-alert

Input

Argument Name Description Required
entity The entity for which the alert should be created. Required

Context Output

Path Type Description
Doppel.CreatedAlert.id String The unique ID of the alert.
Doppel.CreatedAlert.entity String The entity URL associated with the alert.
Doppel.CreatedAlert.doppel_link String The link to view the alert in the Doppel platform.

Command example

!doppel-create-alert entity="http://example.com"

Context Example

{
  "CreatedAlert": {
    "id": "TST-1001",
    "entity": "http://example.com",
    "doppel_link": "https://app.doppel.com/alerts/TST-1001"
  }
}

Human Readable Output

Created Alert

ID Entity Doppel Link
TST-1001 http://example.com Doppel Link

doppel-update-alert


Updates an alert in the Doppel platform. Either alert_id or entity must be specified.

Base Command

doppel-update-alert

Input

Argument Name Description Required
alert_id The ID of the alert to update. Either alert_id or entity should be specified, not both. Optional
entity The entity of the alert to update. Either alert_id or entity should be specified, not both. Optional
queue_state Status of which queue the alert is in. Possible values are: doppel_review, actioned, needs_confirmation, monitoring, taken_down, archived. Optional
entity_state State of the alert. Possible values are: active, down, parked. Optional
comment A comment to add while updating the alert. Optional

Context Output

Path Type Description
Doppel.UpdatedAlert.id String Unique identifier for the alert (e.g., TET-1953421).
Doppel.UpdatedAlert.entity String The URL or identifier associated with the alert.
Doppel.UpdatedAlert.brand String The brand associated with the alert.
Doppel.UpdatedAlert.queue_state String The current state of the alert in the queue.
Doppel.UpdatedAlert.entity_state String The current state of the alert entity.
Doppel.UpdatedAlert.severity String The severity of the alert (e.g., Low, Medium, High).
Doppel.UpdatedAlert.product String The product category related to the alert.
Doppel.UpdatedAlert.platform String The platform associated with the alert (e.g., Bluesky).
Doppel.UpdatedAlert.source String The source of the alert (e.g., Analyst Upload).
Doppel.UpdatedAlert.notes Unknown Additional notes regarding the alert.
Doppel.UpdatedAlert.created_at Date Timestamp when the alert was created.
Doppel.UpdatedAlert.doppel_link String URL link to the alert in Doppel Vision.
Doppel.UpdatedAlert.entity_content Unknown Content details of the alert entity.
Doppel.UpdatedAlert.audit_logs.timestamp Date Timestamp of the audit log entry.
Doppel.UpdatedAlert.audit_logs.type String The type of audit log entry.
Doppel.UpdatedAlert.audit_logs.value String The value associated with the audit log entry.
Doppel.UpdatedAlert.audit_logs.changed_by String The user who changed the alert, or null if system-generated.
Doppel.UpdatedAlert.audit_logs.metadata Unknown Additional metadata related to the audit log entry.
Doppel.UpdatedAlert.tags Unknown List of tags associated with the alert.
Doppel.UpdatedAlert.uploaded_by String The user or system that uploaded the alert (e.g., Doppel).

Command example

!doppel-update-alert alert_id="TST-31" queue_state="actioned" entity_state="down" comment="Updated due to new findings"

Context Example

{
  "Doppel": {
    "UpdatedAlert": {
      "id": "TST-31",
      "entity": "http://dummyrul.com",
      "brand": "test_brand",
      "queue_state": "actioned",
      "entity_state": "down",
      "severity": "medium",
      "product": "domains",
      "platform": "domains",
      "source": "Analyst Upload",
      "notes": null,
      "created_at": "2024-11-27T06:51:50.357664",
      "doppel_link": "https://app.doppel.com/alerts/TST-31222",
      "entity_content": {
        "root_domain": {
          "domain": "dummyrul.com",
          "registrar": null,
          "ip_address": null,
          "country_code": null,
          "hosting_provider": null,
          "contact_email": null
        }
      },
      "audit_logs": [
        {
          "timestamp": "2024-11-27T06:51:50.357664",
          "type": "alert_update",
          "value": "actioned",
          "changed_by": "currentuser@doppel.com",
          "metadata": {}
        }
      ],
      "tags": [],
      "uploaded_by": "currentuser@doppel.com"
    }
  }
}

Human Readable Output

Updated Alert Details

ID Entity Brand Queue State Entity State Severity Product Platform Source Created At Doppel Link Uploaded By
TST-31 http://dummyrul.com test_brand actioned down medium domains domains Analyst Upload 2024-11-27T06:51:50.357664 Doppel Link currentuser@doppel.com

doppel-create-abuse-alert


Create an alert for the provided value to abuse box. Will fail if the alert value is invalid or is protected.

Base Command

doppel-create-abuse-alert

Input

Argument Name Description Required
entity The entity for which the abuse alert should be created. Required

Context Output

Path Type Description
Doppel.AbuseAlert.message String Message indicating the status of the submission process.

Command example

!doppel-create-abuse-alert entity="http://malicious.com"

Context Example

{
  "Doppel": {
    "AbuseAlert": {
      "message": "Abuse alert created successfully"
    }
  }
}

Human Readable Output

Abuse Alert Submission

Message
Abuse alert created successfully

doppel-get-alerts


Retrieves a list of alerts. The result can be filtered by provided parameters.

Base Command

doppel-get-alerts

Input

Argument Name Description Required
search_key Currently only supports search by URL. Optional
queue_state New queue status to update alert with (ID required). Possible values: actioned, needs_confirmation, doppel_review, monitoring, taken_down, archived. Optional
product Product category the report belongs to. Possible values: domains, social_media, mobile_apps, ecommerce, crypto, email, paid_ads, telco, darkweb. Optional
created_before Filter alerts created before a specific time. Use the ISO 8601 format, such as 2020-01-01T00:11:22Z. For durations, enter values like ‘12 hours’ or ‘7 days’. Optional
created_after Filter alerts created after a specific time. Use the ISO 8601 format, such as 2020-01-01T00:11:22Z. For durations, enter values like ‘12 hours’ or ‘7 days’. Optional
sort_type The field to sort the reports by. Defaults to date_sourced. Possible values: date_sourced, date_last_actioned. Optional
sort_order The order to sort the reports by. Defaults to desc. Possible values: asc, desc. Optional
page Page number for pagination; defaults to 0. Optional
tags List of tags to filter alerts. Optional

Context Output

Path Type Description
Doppel.GetAlerts.alerts.id String The unique ID of the alert.
Doppel.GetAlerts.alerts.entity String The entity associated with the alert.
Doppel.GetAlerts.alerts.brand String The brand related to the alert.
Doppel.GetAlerts.alerts.queue_state String The queue state of the alert.
Doppel.GetAlerts.alerts.entity_state String The current state of the entity (active/inactive).
Doppel.GetAlerts.alerts.severity String The severity of the alert.
Doppel.GetAlerts.alerts.product String The product related to the alert.
Doppel.GetAlerts.alerts.platform String The platform associated with the alert.
Doppel.GetAlerts.alerts.source String The source of the alert.
Doppel.GetAlerts.alerts.created_at Date The timestamp when the alert was created.
Doppel.GetAlerts.alerts.doppel_link String The link to the alert in the Doppel platform.
Doppel.GetAlerts.alerts.entity_content.root_domain.domain String The domain associated with the alert.
Doppel.GetAlerts.alerts.entity_content.root_domain.registrar String The registrar of the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.ip_address String The IP address of the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.country_code String The country code of the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.hosting_provider String The hosting provider for the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.contact_email String The contact email of the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.mx_records Unknown The MX records associated with the domain.
Doppel.GetAlerts.alerts.entity_content.root_domain.nameservers Unknown The nameservers associated with the domain.
Doppel.GetAlerts.alerts.audit_logs.timestamp Date The timestamp of the audit log.
Doppel.GetAlerts.alerts.audit_logs.type String The type of the audit log.
Doppel.GetAlerts.alerts.audit_logs.value String The value associated with the audit log.
Doppel.GetAlerts.alerts.audit_logs.changed_by String The user or system that changed the status.
Doppel.GetAlerts.alerts.audit_logs.metadata Unknown Additional metadata for the audit log.
Doppel.GetAlerts.alerts.tags Unknown The tags associated with the alert.
Doppel.GetAlerts.alerts.uploaded_by String The source or user who uploaded the alert.
Doppel.GetAlerts.metadata.count Integer The total count of alerts.
Doppel.GetAlerts.metadata.page Integer The current page number in the results.
Doppel.GetAlerts.metadata.total_pages Integer The total number of pages for the alerts.
Doppel.GetAlerts.metadata.page_size Integer The number of alerts per page.

Command example

!doppel-get-alerts search_key="http://example.com" sort_order="desc" page="1"

Context Example

{
  "Doppel": {
    "GetAlerts": {
      "alerts": [
        {
          "id": "ALERT-12345",
          "entity": "http://example.com",
          "brand": "Test Brand",
          "queue_state": "doppel_review",
          "entity_state": "active",
          "severity": "high",
          "product": "domains",
          "platform": "website",
          "source": "Analyst Upload",
          "created_at": "2024-11-27T10:20:30Z",
          "doppel_link": "https://app.doppel.com/alerts/ALERT-12345",
          "entity_content": {
            "root_domain": {
              "domain": "example.com",
              "registrar": "Example Registrar",
              "ip_address": "192.168.1.1",
              "country_code": "US",
              "hosting_provider": "Example Hosting",
              "contact_email": "contact@example.com"
            }
          },
          "audit_logs": [
            {
              "timestamp": "2024-11-27T10:21:00Z",
              "type": "alert_create",
              "value": "needs_review",
              "changed_by": "analyst@example.com",
              "metadata": {}
            }
          ],
          "tags": ["phishing", "malware"],
          "uploaded_by": "analyst@example.com"
        }
      ],
      "metadata": {
        "count": 1,
        "page": 1,
        "total_pages": 1,
        "page_size": 50
      }
    }
  }
}

Human Readable Output

Alert Details

ID Entity Brand Queue State Entity State Severity Product Platform Source Created At Doppel Link Uploaded By
ALERT-12345 http://example.com Test Brand doppel_review active high domains website Analyst Upload 2024-11-27T10:20:30Z Doppel Link analyst@example.com

get-mapping-fields


Returns the list of fields for an incident type.

Base Command

get-mapping-fields

Input

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

Context Output

There is no context output for this command.

update-remote-system


Pushes local changes to the remote system. If an XSOAR Incident is closed, created via Doppel Integration, then on Doppel Portal the Alert status will be updated to archived status.

Base Command

update-remote-system

Input

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

Context Output

There is no context output for this command.

get-remote-data


Get remote data from a remote incident. This method does not update the current incident, and should be used for debugging purposes.

Base Command

get-remote-data

Input

Argument Name Description Required
id The ticket ID. Required
lastUpdate Retrieve entries that were created after lastUpdate. Required

Context Output

There is no context output for this command.

get-modified-remote-data


Gets the list of incidents that were modified since the last update time. Note that this method is here for debugging purposes. The get-modified-remote-data command is used as part of a Mirroring feature, which is available from version 6.1.

Base Command

get-modified-remote-data

Input

Argument Name Description Required
last_update Retrieve entries that were created after lastUpdate. Optional

Context Output

There is no context output for this command.

Incident Mirroring

Note: The fetch-incidents command creates Cortex XSOAR incidents only for newly detected Doppel alerts. Changes made to a Doppel alert after it was fetched (such as queue state, entity state, or severity updates) are not pulled in by fetch. To keep existing incidents up to date with Doppel, enable mirroring and set the Mirror Direction parameter to Incoming or Incoming And Outgoing.

Mirroring In (Doppel → XSOAR)

When incidents are mirrored into Cortex XSOAR from Doppel:

  1. Any changes in Doppel alerts (mirroring incoming fields) will be reflected in Cortex XSOAR incidents.

Supported Fields

The following fields are mirrored:

  1. Doppel Queue State – Indicates the queue where the alert is currently assigned.
  2. Doppel Entity State – Represents the status of the alert.
  3. Audit Log History – A record of all changes made to the alert.

Mirroring Out (XSOAR → Doppel)

When incidents are mirrored out from Cortex XSOAR to Doppel. Currently, the Mirror-out feature updates the Doppel Alert queue to an archived state upon XSOAR incident closure. We intend to extend this capability in future releases to provide more comprehensive mirroring.

Supported Fields

The following fields are mirrored:

  1. Doppel Queue State – Indicates the queue where the alert is currently assigned.
  2. Close Notes – When an incident is closed with close notes, the notes are sent to Doppel and recorded as a comment on the alert.

Data sharing note: Closing an incident sends its analyst-authored close notes to the Doppel platform as an alert comment, where they are visible to anyone with access to the alert in Doppel. If your close notes may contain sensitive internal information, review them before closing, or remove the closeNotes mapping from the Doppel Outgoing_Mapper to disable this behavior.

Configuration Steps

To enable incident mirroring between Cortex XSOAR incidents and Doppel alerts:

  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 Doppel alerts (mirroring incoming fields) will be reflected in Cortex XSOAR incidents.
    Outgoing Mirroring out will only update queue state in Doppel alerts (outgoing mirrored fields).
    Incoming And Outgoing Changes in Cortex XSOAR incidents and Doppel alerts 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 Doppel.

<~PLATFORM>

License Requirements

The following configuration parameters require one of these licenses: Cortex XSIAM or Agentix:

  • Fetch incidents

</~PLATFORM>

Configuration parameters

  • url — Doppel Tenant URL (required)
  • credentials — (required)
  • user_credentials
  • organization_code — Organization Code
  • isFetch — Fetch incidents
  • incidentFetchInterval — Incidents Fetch Interval (required)
  • incidentType — Incident type
  • mirror_direction — Mirror Direction
  • first_fetch — First fetch
  • fetch_timeout — Fetch incidents timeout
  • max_fetch — Number of incidents for each fetch.
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (9)

  • doppel-create-abuse-alert

    Create an alert for the provided value to abuse box. Will fail if the alert value is invalid or is protected.

  • doppel-create-alert

    Creates an alert for a specified entity. This command requires the entity to be provided in the arguments.

  • doppel-get-alert

    Retrieves the alert details by ID or entity. Must include either ID or entity.

  • doppel-get-alerts

    Retrieves a list of alerts. The result can be filtered by provided parameters.

  • doppel-update-alert

    Updates an alert in the Doppel platform. Either 'alert_id' or 'entity' must be specified.

  • get-mapping-fields

    Returns the list of fields for an incident type.

  • get-modified-remote-data

    Get the list of incidents that were modified since the last update time. This method is used for debugging purposes. The get-modified-remote-data command is used as part of the Mirroring feature that was introduced in Cortex XSOAR version 6.1.

  • get-remote-data

    Get remote data from a remote incident. This method does not update the current incident, and should be used for debugging purposes only.

  • update-remote-system

    Pushes local changes to the remote system.

import demistomock as demisto
from CommonServerPython import *
from CommonServerUserPython import *

import json
import traceback
from datetime import datetime, UTC

"""Doppel for Cortex XSOAR (aka Demisto)

This integration contains features to mirror the alerts from Doppel to create incidents in XSOAR
and the commands to perform different updates on the alerts
"""

import urllib3
from typing import Any, Callable  # noqa: UP035

# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """
XSOAR_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
DOPPEL_API_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
DOPPEL_PAYLOAD_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
MIRROR_DIRECTION = {
    "None": None,
    "Incoming": "In",
    "Outgoing": "Out",
    "Incoming And Outgoing": "Both",
}
DOPPEL_ALERT = "Doppel Alert"
DOPPEL_INCIDENT = "Doppel Incident"
DEFAULT_RETRY_TOTAL = 3
DEFAULT_RETRY_BACKOFF_FACTOR = 2
DEFAULT_RETRY_STATUS_LIST = [429, 500, 502, 503, 504]
# Doppel's get-alerts API caps a page at 200 results; request the max to drain backlogs in fewer calls.
DOPPEL_MAX_PAGE_SIZE = 200
# Fallback when max_fetch is blank/invalid.
DEFAULT_MAX_FETCH = 10
# Hard ceiling on pages pulled in a single fetch run, so a misbehaving API can never spin forever.
MAX_FETCH_PAGES_PER_RUN = 1000
# Map Doppel alert severities to XSOAR incident severities (0 = Unknown).
SEVERITY_MAP = {"low": 1, "medium": 2, "high": 3, "critical": 4}


""" CLIENT CLASS """


class Client(BaseClient):
    """Client class to interact with the service API

    This Client implements API calls, and does not contain any XSOAR logic.
    Should only do requests and return data.
    It inherits from BaseClient defined in CommonServer Python.
    Most calls use _http_request() that handles proxy, SSL verification, etc.
    For this  implementation, no special attributes defined
    """

    def __init__(
        self,
        base_url,
        api_key,
        user_api_key=None,
        organization_code=None,
        verify=None,
        proxy=None,
        retry_total=DEFAULT_RETRY_TOTAL,
        retry_backoff_factor=DEFAULT_RETRY_BACKOFF_FACTOR,
        retry_status_list=DEFAULT_RETRY_STATUS_LIST,
    ):
        super().__init__(base_url, verify=verify, proxy=proxy)

        self._headers = {"accept": "application/json", "x-api-key": api_key}
        if user_api_key:
            self._headers["x-user-api-key"] = user_api_key
        if organization_code:
            self._headers["x-organization-code"] = organization_code

        # Store retry configuration on the client and leverage BaseClient._http_request parameters
        self._retries = retry_total
        self._backoff_factor = retry_backoff_factor
        self._status_list_to_retry = retry_status_list

        demisto.debug(
            f"Initialized HTTP client using BaseClient._http_request retry params: total={retry_total}, "
            f"backoff_factor={retry_backoff_factor}, status_list={retry_status_list}"
        )

    def get_alert(self, id: str, entity: str) -> dict[str, Any]:
        """Return the alert's details when provided the Alert ID or Entity as input

        :type id: ``str``
        :param id: Alert id for which we need to fetch details

        :type entity: ``str``
        :param entity: Alert id for which we need to fetch details

        :return: dict as with alert's details
        :rtype: ``dict``
        """
        params: dict = {}
        if id:
            params["id"] = id
        if entity:
            params["entity"] = entity

        response_content = self._http_request(
            method="GET",
            url_suffix="alert",
            params=params,
            retries=self._retries,
            backoff_factor=self._backoff_factor,
            status_list_to_retry=self._status_list_to_retry,
        )
        return response_content

    def update_alert(
        self,
        queue_state: str,
        entity_state: str,
        alert_id: str | None = None,
        entity: str | None = None,
        comment: str | None = None,
    ) -> dict[str, Any]:
        """
        Updates an existing alert using either the alert ID or the entity.

        :param queue_state: The queue state to update to.
        :param entity_state: The entity state to update to.
        :param alert_id: The alert ID (optional).
        :param entity: The entity (optional).
        :param comment: The comment (optional).
        :return: JSON response containing the updated alert.
        """
        if alert_id and entity:
            raise ValueError("Only one of 'alert_id' or 'entity' can be specified, not both.")
        if not alert_id and not entity:
            raise ValueError("Either 'alert_id' or 'entity' must be specified.")

        api_name = "alert"
        api_url = f"{self._base_url}/{api_name}"
        params = {}
        if alert_id:
            params["id"] = alert_id
        elif entity:
            params["entity"] = entity
        payload = {"queue_state": queue_state, "entity_state": entity_state, "comment": comment}

        response_content = self._http_request(
            method="PUT",
            full_url=api_url,
            params=params,
            json_data=payload,
            retries=self._retries,
            backoff_factor=self._backoff_factor,
            status_list_to_retry=self._status_list_to_retry,
        )
        return response_content

    def get_alerts(self, params: dict[str, Any]) -> dict[str, Any]:
        """
        Fetches multiple alerts based on query parameters.

        :param params: A dictionary of query parameters to apply to the request.
        :return: A list of dictionaries containing alert details.
        """
        api_name = "alerts"
        api_url = f"{self._base_url}/{api_name}"
        # Filter out None values
        filtered_params = {k: v for k, v in params.items() if v is not None}

        demisto.debug(f"API Request Params: {filtered_params}")

        response_content = self._http_request(
            method="GET",
            full_url=api_url,
            params=filtered_params,
            retries=self._retries,
            backoff_factor=self._backoff_factor,
            status_list_to_retry=self._status_list_to_retry,
        )
        return response_content

    def create_alert(self, entity: str) -> dict[str, Any]:
        api_name = "alert"
        api_url = f"{self._base_url}/{api_name}"
        response_content = self._http_request(
            method="POST",
            full_url=api_url,
            json_data={"entity": entity},
            retries=self._retries,
            backoff_factor=self._backoff_factor,
            status_list_to_retry=self._status_list_to_retry,
        )
        return response_content

    def create_abuse_alert(self, entity: str) -> dict[str, Any]:
        api_name = "alert/abuse"
        api_url = f"{self._base_url}/{api_name}"
        response_content = self._http_request(
            method="POST",
            full_url=api_url,
            json_data={"entity": entity},
            retries=self._retries,
            backoff_factor=self._backoff_factor,
            status_list_to_retry=self._status_list_to_retry,
        )
        return response_content


""" HELPER FUNCTIONS """


def _normalize_entity_content_for_grid(entity_content: Any) -> list[dict[str, Any]]:
    """
    Convert Doppel entity_content into a list of row dicts for the grid incident field.

    Domains alerts nest fields under ``root_domain``; the XSOAR grid field expects a list of
    flat objects whose keys match the grid columns (domain, registrar, ip_address, ...).
    """
    if not entity_content:
        return []
    if isinstance(entity_content, list):
        return [row for row in entity_content if isinstance(row, dict)]
    if not isinstance(entity_content, dict):
        return []

    root_domain = entity_content.get("root_domain")
    if isinstance(root_domain, dict):
        return [root_domain]
    # Other product shapes: unwrap a single nested dict when present
    nested_dicts = [value for value in entity_content.values() if isinstance(value, dict)]
    if len(nested_dicts) == 1:
        return [nested_dicts[0]]
    # Already a flat row (no nested objects): use it as-is so no data is dropped
    if not nested_dicts:
        return [entity_content]
    return []


def _get_remote_updated_incident_data_with_entry(client: Client, doppel_alert_id: str, last_update_str: str):
    """
    Retrieves updated incident data from the remote system based on the given alert ID and last update timestamp.

    Args:
        client (Client):
            An instance of the Client class used to interact with the remote Doppel API.
        doppel_alert_id (str):
            The unique identifier of the alert in the remote system.
        last_update_str (str):
            A string representing the last update timestamp in ISO 8601 format (e.g., "2025-01-19T08:44:52Z").

    Returns:
        dict[str, Any]:
            A dictionary containing the updated incident details, including entries related to the alert.
    """

    # A never-synced incident carries "0001-01-01T00:00:00Z" (no microseconds), which the
    # previous strict strptime call could not parse, so the first sync never completed.
    # The timestamp is only informational here (the server already filtered this incident as
    # modified via get-modified-remote-data), so a parse failure must not block the sync.
    try:
        last_update = arg_to_datetime(last_update_str, required=False)
    except ValueError:
        last_update = None
    if not last_update:
        demisto.debug(f"Doppel - Could not parse lastUpdate timestamp {last_update_str!r}; syncing anyway.")

    demisto.debug(f"Getting Remote Data for {doppel_alert_id} which was last updated on: {last_update}")
    updated_doppel_alert = client.get_alert(id=doppel_alert_id, entity="")
    demisto.debug(f"Received alert data for {doppel_alert_id}")
    if not updated_doppel_alert:
        demisto.debug(f"Doppel - No alert data returned for {doppel_alert_id}.")
        return None, []
    updated_doppel_alert["id"] = doppel_alert_id

    # Shape entity_content for the grid incident field (mapper expects a list of row dicts).
    if "entity_content" in updated_doppel_alert:
        updated_doppel_alert["entity_content"] = _normalize_entity_content_for_grid(updated_doppel_alert.get("entity_content"))

    # Attach the most recent audit-log event as a War Room note when available, but never
    # let a missing/empty audit trail discard the field updates themselves.
    entries: list = []
    audit_logs = updated_doppel_alert.get("audit_logs")
    demisto.debug(f"The alert contains {len(audit_logs) if isinstance(audit_logs, list) else 0} audit logs")
    if isinstance(audit_logs, list) and audit_logs and all(isinstance(log, dict) for log in audit_logs):
        # ISO-8601 timestamps sort lexicographically, so string comparison is safe and avoids
        # strptime failures on entries without microseconds.
        most_recent_audit_log = max(audit_logs, key=lambda audit_log: str(audit_log.get("timestamp") or ""))
        demisto.debug(f"Most recent audit log is {most_recent_audit_log}")
        entries = [{"Type": EntryType.NOTE, "Contents": most_recent_audit_log, "ContentsFormat": EntryFormat.JSON, "Note": True}]

    demisto.debug(f"Successfully returning the updated alert and entries: {updated_doppel_alert, entries}")
    return updated_doppel_alert, entries


def _get_mirroring_fields():
    """
    Get tickets mirroring.
    """
    mirror_direction: str = demisto.params().get("mirror_direction", "None")
    return {
        "mirror_direction": MIRROR_DIRECTION.get(mirror_direction),
        "mirror_instance": demisto.integrationInstance(),
        "incident_type": "Doppel_Incident",
    }


def _get_last_fetch_datetime(last_run):
    # Fetch the last run (time of the last fetch)
    last_fetch_datetime: datetime = datetime.now()
    if last_run:
        last_fetch_datetime = datetime.strptime(last_run, "%Y-%m-%dT%H:%M:%SZ")
        demisto.debug(f"Alerts were fetched last on: {last_fetch_datetime}")
    else:
        # If no last run is found
        first_fetch_time = demisto.params().get("first_fetch", "3 days").strip()
        try:
            last_fetch_datetime = arg_to_datetime(first_fetch_time, required=False) or datetime.now()
        except ValueError:
            last_fetch_datetime = datetime.now()
        demisto.debug(f"This is the first time we are fetching the incidents. This time fetching it from: {last_fetch_datetime}")

    return last_fetch_datetime


def _paginated_call_to_get_alerts(client, page, last_fetch_datetime):
    """
    Set the query parameters
    """
    last_fetch_str: str = last_fetch_datetime.strftime(DOPPEL_API_DATE_FORMAT)
    query_params = {
        "created_after": last_fetch_str,  # Fetch alerts after the last_fetch,
        "sort_type": "date_sourced",
        "sort_order": "asc",
        "page": page,
        "page_size": DOPPEL_MAX_PAGE_SIZE,  # Pull large pages to drain backlogs in fewer requests (API max is 200)
    }
    get_alerts_response = client.get_alerts(params=query_params)
    alerts = get_alerts_response.get("alerts", None)
    return alerts


""" COMMAND FUNCTIONS """


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

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

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

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

    try:
        # Using the same dates so that we do not fetch any data for testing,
        # but still get the response as 200
        current_datetime_str = datetime.now().strftime(DOPPEL_API_DATE_FORMAT)
        query_params = {"created_before": current_datetime_str, "created_after": current_datetime_str}

        # Call the client's `get_alerts` method to test the connection
        client.get_alerts(params=query_params)
        message: str = "ok"

    except DemistoException as e:
        if "Forbidden" in str(e) or "Authorization" in str(e):
            message = "Authorization Error: make sure API Key is correctly set"
        else:
            raise e
    return message


def doppel_get_alert_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Comand to get a specific alert in the Doppel client using the provided arguments.

    :param client: Client instance to interact with the API.
    :param args: Command arguments containing the query parameters as key-value pairs.
    :return: CommandResults object including alert details.

    """

    id: str = args.get("id", "")
    entity: str = args.get("entity", "")
    if not id and not entity:
        raise ValueError("Neither id nor the entity is specified. We need exactly single input for this command")
    if id and entity:
        raise ValueError("Both id and entity is specified. We need exactly single input for this command")

    try:
        result = client.get_alert(id=id, entity=entity)
    except Exception as exception:
        raise Exception(f"No alert found with the given parameters :- {str(exception)}")

    title = "Alert Summary"
    human_readable = tableToMarkdown(title, result, removeNull=True)
    return CommandResults(
        outputs_prefix="Doppel.Alert",
        outputs_key_field="id",
        outputs=result,
        readable_output=human_readable,
        raw_response=result,
    )


def doppel_update_alert_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Executes the update alert command.

    :param client: The Client instance.
    :param args: Command arguments.
    :return: CommandResults object.
    """
    demisto.debug(f"Update Alert cmd params: {args}")
    alert_id = args.get("alert_id", "")
    entity = args.get("entity", "")
    queue_state = args.get("queue_state", "")
    entity_state = args.get("entity_state", "")
    comment = args.get("comment", "")

    if alert_id and entity:
        raise ValueError("Only one of 'alert_id' or 'entity' can be specified.")

    if not any([queue_state, entity_state, comment]):
        raise ValueError("At least one of 'queue_state', 'entity_state', or 'comment' must be provided.")

    try:
        result = client.update_alert(
            queue_state=queue_state, entity_state=entity_state, alert_id=alert_id, entity=entity, comment=comment
        )
    except Exception as exception:
        raise Exception(f"Failed to update the alert with the given parameters :- {str(exception)}.")

    title = "Alert Summary"
    human_readable = tableToMarkdown(title, result, removeNull=True)
    return CommandResults(
        outputs_prefix="Doppel.UpdatedAlert",
        outputs_key_field="id",
        outputs=result,
        readable_output=human_readable,
        raw_response=result,
    )


def format_datetime(timestamp_str):
    """
    Formats a given timestamp string into ISO 8601 format.

    :param timestamp_str: A string representing the datetime, which may or may not be in ISO 8601 format.
    :return: A formatted datetime string in ISO 8601 format (YYYY-MM-DDTHH:MM:SS).
    """
    if not timestamp_str:
        return None  # Return None if no timestamp is provided

    try:
        # Replace 'Z' with '+00:00' to make it compatible with fromisoformat()
        if timestamp_str.endswith("Z"):
            timestamp_str = timestamp_str.replace("Z", "+00:00")

        # Attempt to parse the string in ISO 8601 format
        datetime.fromisoformat(timestamp_str)
        return timestamp_str  # Already in ISO format
    except ValueError:
        datetime_obj = arg_to_datetime(timestamp_str)

        # Convert to standard ISO 8601 format without microseconds and timezone
        iso_format_truncated = datetime_obj.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if datetime_obj else None
        return iso_format_truncated


def doppel_get_alerts_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Command to fetch multiple alerts based on query parameters.

    :param client: Client instance to interact with the API.
    :param args: Command arguments containing the query parameters as key-value pairs.
    :return: CommandResults object with the retrieved alerts.
    """

    created_before = format_datetime(args.get("created_before"))
    created_after = format_datetime(args.get("created_after"))

    # Extract query parameters directly from arguments
    query_params = {
        "search_key": args.get("search_key"),
        "queue_state": args.get("queue_state"),
        "product": args.get("product"),
        "created_before": created_before,
        "created_after": created_after,
        "sort_type": args.get("sort_type"),
        "sort_order": args.get("sort_order"),
        "page": args.get("page"),
        "tags": argToList(args.get("tags"), separator=",", transform=None),
    }

    # Call the client's `get_alerts` method to fetch data
    demisto.debug(f"Query parameters before sending to client: {query_params}")

    try:
        results = client.get_alerts(params=query_params)
    except Exception as exception:
        raise Exception(f"No alerts were found with the given parameters :- {str(exception)}.")
    demisto.debug(f"Results received: {results}")

    alerts = results.get("alerts")
    title = "Alert Summary"
    human_readable = tableToMarkdown(title, alerts, removeNull=True)
    return CommandResults(
        outputs_prefix="Doppel.GetAlerts",
        outputs_key_field="id",
        outputs=results,
        readable_output=human_readable,
        raw_response=results,
    )


def doppel_create_alert_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Comand to create an alert in the Doppel client using the provided arguments.

    :param client: Client instance to interact with the API.
    :param args: Command arguments containing the query parameters as key-value pairs.
    :return: CommandResults object including details of the created alert.
    """

    entity = args.get("entity")
    if not entity:
        raise ValueError("Entity must be specified to create an alert.")

    try:
        result = client.create_alert(entity=entity)
    except Exception as exception:
        raise Exception(f"Failed to create the alert with the given parameters:- {str(exception)}.")

    title = "Alert Summary"
    human_readable = tableToMarkdown(title, result, removeNull=True)
    return CommandResults(
        outputs_prefix="Doppel.CreatedAlert",
        outputs_key_field="id",
        outputs=result,
        readable_output=human_readable,
        raw_response=result,
    )


def doppel_create_abuse_alert_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Comand to create an abuse alert in the Doppel client using the provided arguments.

    :param client: Client instance to interact with the API.
    :param args: Command arguments containing the query parameters as key-value pairs.
    :return: CommandResults object including details of the created abuse alert.

    """

    entity = args.get("entity")
    if not entity:
        raise ValueError("Entity must be specified to create an abuse alert.")

    try:
        result = client.create_abuse_alert(entity=entity)
    except Exception as exception:
        raise Exception(f"Failed to create the abuse alert with the given parameters:- {str(exception)}.")

    title = "Alert Summary"
    human_readable = tableToMarkdown(title, result, removeNull=True)
    return CommandResults(
        outputs_prefix="Doppel.AbuseAlert",
        outputs_key_field="id",
        outputs=result,
        readable_output=human_readable,
        raw_response=result,
    )


def _parse_fetch_timeout():
    """Parse the fetch_timeout param. Blank or invalid means no timeout limit."""
    raw = demisto.params().get("fetch_timeout")
    if raw is None or str(raw).strip() == "":
        return None
    try:
        return float(raw)
    except (TypeError, ValueError):
        return None


def _parse_max_fetch():
    """Parse the max_fetch param, falling back to a safe default when blank/invalid."""
    raw = demisto.params().get("max_fetch")
    try:
        value = int(raw)
    except (TypeError, ValueError):
        return DEFAULT_MAX_FETCH
    return value if value > 0 else DEFAULT_MAX_FETCH


def _incident_alert_id(incident):
    """Return the Doppel alert id for a queued/created incident (dbotMirrorId, else rawJSON id)."""
    alert_id = str(incident.get("dbotMirrorId") or "")
    if alert_id:
        return alert_id
    raw = incident.get("rawJSON")
    if raw:
        try:
            return str(json.loads(raw).get("id") or "")
        except Exception:
            return ""
    return ""


def _xsoar_severity(alert):
    """Map a Doppel alert severity string to an XSOAR incident severity (0 = Unknown)."""
    return SEVERITY_MAP.get(str(alert.get("severity") or "").strip().lower(), 0)


def _alert_to_incident(alert, mirroring_object):
    """Build an XSOAR incident dict from a single Doppel alert."""
    alert_id = str(alert.get("id") or "")
    created_at_str = alert.get("created_at")
    created_at_datetime = None
    if created_at_str:
        for date_format in (DOPPEL_PAYLOAD_DATE_FORMAT, DOPPEL_API_DATE_FORMAT):
            try:
                created_at_datetime = datetime.strptime(created_at_str, date_format).replace(tzinfo=UTC)
                break
            except (ValueError, TypeError):
                continue
    if created_at_datetime is None:
        created_at_datetime = datetime.now(UTC)
    severity = _xsoar_severity(alert)

    # Shape entity_content for the grid incident field before it is mapped from rawJSON.
    if "entity_content" in alert:
        alert["entity_content"] = _normalize_entity_content_for_grid(alert.get("entity_content"))

    alert.update(mirroring_object)
    # Use the external Doppel alert id (e.g. TET-1953421) as the incident name so it
    # is human-meaningful and duplicates are visually obvious.
    incident_name = f"Doppel Alert {alert_id}" if alert_id else "Doppel Alert"
    return {
        "name": incident_name,
        "type": DOPPEL_ALERT,
        "occurred": created_at_datetime.strftime(XSOAR_DATE_FORMAT),
        "severity": severity,
        "dbotMirrorId": alert_id,
        "rawJSON": json.dumps(alert),
    }


def fetch_incidents_command(client: Client, args: dict[str, Any]) -> None:
    """
    Fetch incidents from Doppel alerts and create XSOAR incidents.

    Alerts are pulled page by page from the last-run cursor in ascending order and
    de-duplicated by their Doppel alert id (both within a single run and across runs
    via a small boundary set), so a large multi-page backlog is drained safely over
    consecutive runs without ever creating the same alert twice. Only lightweight
    cursor metadata is persisted in lastRun - never raw incident data.
    """
    demisto.debug("Doppel - Fetching alerts from Doppel.")
    start_time = time.time()
    timeout = _parse_fetch_timeout()
    fetch_limit = _parse_max_fetch()
    last_run = demisto.getLastRun() or {}
    demisto.debug(f"Doppel - Last run details: {last_run}")
    recently_seen_ids = last_run.get("recently_seen_ids", [])
    last_run_time = last_run.get("last_run", None)
    last_fetch_datetime = _get_last_fetch_datetime(last_run_time)
    # Seed the dedupe set with the ids persisted from the previous run's high-water-mark
    # second. Because the next run's cursor re-pull is inclusive of that second, this
    # prevents re-creating alerts that were already emitted at the boundary second.
    seen_alert_ids = {str(i) for i in recently_seen_ids if i}
    mirroring_object = _get_mirroring_fields()

    incidents: list[dict[str, Any]] = []
    page = 0
    while len(incidents) < fetch_limit:
        if timeout is not None and (time.time() - start_time) > timeout:
            demisto.debug("Doppel - Fetch reached its time budget; progress saved, the next run continues.")
            break
        # Hard safeguard against an unbounded loop (e.g. a misbehaving API that never
        # returns an empty page) when no fetch timeout is configured.
        if page >= MAX_FETCH_PAGES_PER_RUN:
            demisto.debug(f"Doppel - Reached the per-run page ceiling ({MAX_FETCH_PAGES_PER_RUN}); continuing next run.")
            break
        alerts = _paginated_call_to_get_alerts(client, page, last_fetch_datetime)
        if not alerts:
            demisto.debug("Doppel - No more alerts returned; exiting pagination loop.")
            break
        for alert in alerts:
            alert_id = str(alert.get("id") or "")
            if not alert_id or alert_id in seen_alert_ids:
                continue
            seen_alert_ids.add(alert_id)
            incidents.append(_alert_to_incident(alert, mirroring_object))
            if len(incidents) >= fetch_limit:
                break
        demisto.debug(f"Doppel - Fetched page {page}; collected {len(incidents)} new alert(s) so far.")
        page += 1

    # Advance the cursor only to the newest alert we are actually creating this run
    # (never past it), so alerts fetched-but-not-created are simply re-pulled next run
    # instead of being buffered in lastRun. We do NOT skip the boundary second; instead
    # we persist the ids emitted at that second so the inclusive re-pull on the next run
    # is de-duplicated rather than lost.
    occurred_times = [str(inc["occurred"]) for inc in incidents if inc.get("occurred")]
    if occurred_times:
        newest_occurred = max(occurred_times)
        next_fetch = newest_occurred
        boundary = {_incident_alert_id(inc) for inc in incidents if inc.get("occurred") == newest_occurred}
        boundary.discard("")
        # While the cursor stays on the same second across runs, carry forward the ids
        # already created on prior runs so the inclusive re-pull never re-creates them.
        if last_run_time == newest_occurred:
            boundary.update(str(i) for i in recently_seen_ids if i)
        boundary_ids = list(boundary)
    else:
        next_fetch = last_run_time
        boundary_ids = [str(i) for i in recently_seen_ids if i]

    demisto.setLastRun(
        {
            "last_run": next_fetch,
            "recently_seen_ids": boundary_ids,
        }
    )
    demisto.debug(f"Doppel - Next cursor: {next_fetch}; boundary ids: {len(boundary_ids)}.")
    # Create incidents in XSOAR (an empty list still registers the run).
    try:
        demisto.incidents(incidents)
        demisto.debug(f"Doppel - Created {len(incidents)} incident(s) in XSOAR.")
    except Exception as e:
        raise ValueError(f"Incident creation failed due to: {str(e)}")


def get_modified_remote_data_command(client: Client, args: dict[str, Any]) -> GetModifiedRemoteDataResponse:
    """
    Checks for remote modifications since the last update timestamp
    and returns a list of modified incident IDs.
    """

    remote_args = GetModifiedRemoteDataArgs(args)
    try:
        last_update_datetime = arg_to_datetime(remote_args.last_update, required=False)
    except ValueError:
        last_update_datetime = None
    if not last_update_datetime:
        raise DemistoException(f"Doppel - Could not parse the lastUpdate timestamp: {remote_args.last_update!r}")
    last_update = last_update_datetime.strftime(DOPPEL_API_DATE_FORMAT)

    try:
        # Page through the full set of modified alerts. Taking only the first page (the
        # previous behavior) silently dropped changes on busy tenants, because the server
        # advances lastUpdate after every cycle regardless of what was returned.
        modified_incident_ids: list[str] = []
        seen_ids: set[str] = set()
        page = 0
        while page < MAX_FETCH_PAGES_PER_RUN:
            query_params = {
                "last_activity_timestamp": last_update,
                "page": page,
                "page_size": DOPPEL_MAX_PAGE_SIZE,
            }
            results = client.get_alerts(params=query_params)
            alerts = results.get("alerts", [])
            if not alerts:
                break
            for alert in alerts:
                alert_id = str(alert.get("id") or "")
                if alert_id and alert_id not in seen_ids:
                    seen_ids.add(alert_id)
                    modified_incident_ids.append(alert_id)
            if len(alerts) < DOPPEL_MAX_PAGE_SIZE:
                break
            page += 1

        demisto.debug(f"Found {len(modified_incident_ids)} modified remote incidents. Incidents: {modified_incident_ids}")
        return GetModifiedRemoteDataResponse(modified_incident_ids)

    except Exception as e:
        demisto.error(f"Error in get-modified-remote-data: {e}")
        return GetModifiedRemoteDataResponse([])


def get_remote_data_command(client: Client, args: dict[str, Any]) -> GetRemoteDataResponse:
    try:
        remote_updated_incident_data: dict[str, Any] = {}
        mirrored_object: dict[str, Any] = {}
        demisto.debug(f'Calling the "get-remote-data" for {args["id"]}')
        parsed_args = GetRemoteDataArgs(args)
        remote_updated_incident_data, parsed_entries = _get_remote_updated_incident_data_with_entry(
            client, parsed_args.remote_incident_id, parsed_args.last_update
        )
        if remote_updated_incident_data:
            demisto.debug(f'Found updates in the alert with id: {args["id"]}')
            return GetRemoteDataResponse(remote_updated_incident_data, parsed_entries)
        else:
            demisto.debug(f"Nothing new in the incident {parsed_args.remote_incident_id}")
            return GetRemoteDataResponse(mirrored_object, entries=[{}])

    except Exception as e:
        demisto.error(f"Error while running get_remote_data_command: {e}")
        if "Rate limit exceeded" in str(e):
            demisto.debug("API rate limit")
        if not remote_updated_incident_data:
            remote_updated_incident_data = {"id": parsed_args.remote_incident_id}
        mirrored_object["in_mirror_error"] = str(e)
        return GetRemoteDataResponse(mirrored_object, entries=[])


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

    Outgoing mirroring only archives the Doppel alert when the XSOAR incident is closed.
    ``entity_state`` is always read from the live Doppel alert so it is preserved even when
    the outgoing mapper does not include it. Close notes from XSOAR are sent as the comment.

    :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

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

    :rtype: ``str``
    """
    parsed_args = UpdateRemoteSystemArgs(args)
    remote_incident_id = parsed_args.remote_incident_id

    # Only update Doppel when the XSOAR incident is closed (status DONE).
    # This command is event-driven on local incident changes; it is not polled every minute.
    if parsed_args.inc_status != IncidentStatus.DONE:
        demisto.debug(f"Incident not closed. Skipping update for remote ID [{remote_incident_id}].")
        return remote_incident_id

    if not remote_incident_id:
        demisto.debug("Doppel - No remote incident id; skipping outgoing update.")
        return remote_incident_id

    demisto.debug(f"Closing remote Doppel alert [{remote_incident_id}] (XSOAR incident closed).")
    try:
        # Always fetch the live alert so entity_state comes from Doppel, not from the
        # outgoing mapper (which currently only maps queue_state).
        current_alert = client.get_alert(id=remote_incident_id, entity="") or {}
        entity_state = current_alert.get("entity_state") or ""
        delta = parsed_args.delta or {}
        data = parsed_args.data or {}
        comment = delta.get("closeNotes") or data.get("closeNotes") or ""

        already_archived = current_alert.get("queue_state") == "archived"
        if already_archived and not comment:
            demisto.debug(f"Doppel alert [{remote_incident_id}] already archived with no close notes; skipping.")
            return remote_incident_id

        client.update_alert(
            queue_state="archived",
            entity_state=entity_state,
            comment=comment,
            alert_id=remote_incident_id,
        )
        # Log only whether close notes were sent, not their content (analyst-authored text).
        demisto.debug(
            f"Doppel - Archived remote alert [{remote_incident_id}] "
            f"with entity_state={entity_state!r} comment_sent={bool(comment)}."
        )
    except Exception as e:
        demisto.error(
            f"Doppel - Error in outgoing mirror for incident {remote_incident_id} "
            f"\nError message: {str(e)}\n{traceback.format_exc()}"
        )

    return remote_incident_id


def get_mapping_fields_command(client: Client, args: dict[str, Any]) -> GetMappingFieldsResponse:
    """
    Retrieves the mapping fields for Doppel alerts in XSOAR.

    This function defines a custom mapping for Doppel alerts, adding specific fields that
    can be used for incident mirroring and enrichment in Cortex XSOAR.

    Args:
        client (Client): The API client used to communicate with Doppel.
        args (dict[str, Any]): Command arguments (not used in this function).

    Returns:
        GetMappingFieldsResponse: The mapping response containing field definitions.
    """

    xdr_incident_type_scheme = SchemeTypeMapping(type_name=DOPPEL_ALERT)
    doppel_fields = {
        "id": "Unique identifier of the alert",
        "entity": "URL or profile link related to the alert",
        "brand": "Brand associated with the alert",
        "queue_state": "Queue State of the Doppel Alert",
        "entity_state": "Current state of the alert entity",
        "severity": "Severity level of the alert",
        "product": "Product category associated with the alert",
        "platform": "Platform on which the alert was generated",
        "source": "Source from which the alert was generated",
        "notes": "Additional notes related to the alert",
        "created_at": "Timestamp when the alert was created",
        "screenshot_url": "URL of the alert screenshot when available",
        "last_activity": "Timestamp of the last activity on the alert",
        "score": "Score assigned to the alert",
        "message": "Message associated with the alert",
        "assignee": "User assigned to the alert",
        "doppel_link": "Link to the alert in the Doppel platform",
        "uploaded_by": "User who uploaded the alert",
        "entity_content": "Additional content related to the alert entity",
        "audit_logs": "Audit log entries for the alert",
        "tags": "Tags associated with the alert",
        "alert_summary": "Summary of the alert",
    }

    for field_name, description in doppel_fields.items():
        xdr_incident_type_scheme.add_field(name=field_name, description=description)

    mapping_response = GetMappingFieldsResponse()
    mapping_response.add_scheme_type(xdr_incident_type_scheme)

    demisto.debug(f"Mapping fields response created: {mapping_response}")
    return mapping_response


""" MAIN FUNCTION """


def main() -> None:
    """Main function, parses params and runs command functions."""
    api_key = demisto.params().get("credentials", {}).get("password")
    user_api_key = demisto.params().get("user_credentials", {}).get("password")
    organization_code = demisto.params().get("organization_code")
    verify = not demisto.params().get("insecure")
    proxy = demisto.params().get("proxy")

    demisto.debug(f"Verify SSL: {verify} and Proxy: {proxy}")

    # Get the service API URL
    base_url = urljoin(demisto.params()["url"], "/v1")

    # Explicitly define the type for the command function dictionary
    supported_commands: dict[str, Callable[[Client, dict[str, Any]], Any]] = {
        "fetch-incidents": fetch_incidents_command,
        "get-modified-remote-data": get_modified_remote_data_command,
        "get-remote-data": get_remote_data_command,
        "update-remote-system": update_remote_system_command,
        "get-mapping-fields": get_mapping_fields_command,
        "doppel-get-alert": doppel_get_alert_command,
        "doppel-update-alert": doppel_update_alert_command,
        "doppel-get-alerts": doppel_get_alerts_command,
        "doppel-create-alert": doppel_create_alert_command,
        "doppel-create-abuse-alert": doppel_create_abuse_alert_command,
    }

    # Special case for 'test-module' which does not take args
    supported_commands_test_module: dict[str, Callable[[Client], Any]] = {"test-module": test_module}

    current_command: str = demisto.command()
    demisto.info(f"Command being called is {current_command}")

    try:
        client = Client(
            base_url=base_url,
            api_key=api_key,
            user_api_key=user_api_key,
            organization_code=organization_code,
            verify=verify,
            proxy=proxy,
        )

        if current_command in supported_commands_test_module:
            # Calls test_module(client) without args
            result = supported_commands_test_module[current_command](client)
        elif current_command in supported_commands:
            # Calls command_function(client, demisto.args())
            result = supported_commands[current_command](client, demisto.args())
        else:
            demisto.error(f"Command is not implemented: {current_command}")
            raise NotImplementedError(f"The {current_command} command is not supported")

        demisto.info(f"Command run successful: {current_command}")
        return_results(result)

    except Exception as e:
        return_error(f"Failed to execute {current_command} command.\nError:\n{str(e)}")


""" ENTRY POINT """


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