EDL

Use the Generic Export Indicators Service integration to provide an endpoint with a list of indicators as a service for the system indicators.

Data Enrichment & Threat Intelligence · Generic Export Indicators Service

Details

IDEDL
ProviderPalo Alto Networks
CategoryData Enrichment & Threat Intelligence
From Version5.5.0
Docker Imagedemisto/flask-nginx:1.0.0.11047721
Supported ModulesAgentix XSIAM EDR Cortex Cloud Cloud Runtime Security

README

Use the Generic Export Indicators Service integration to provide an endpoint with a list of indicators as a service for the system indicators.

The Generic Export Indicators Service integration is a long-running integration. For more information about long-running integrations, see the Cortex XSOAR 8 Cloud, Cortex XSOAR 8 On-prem or Cortex XSIAM documentation.

PAN-OS EDL Management to Export Indicators Service (PAN-OS EDL Service) migration steps

Unlike PAN-OS EDL Management, this integration hosts the EDL on the Cortex XSOAR server. Follow these steps to migrate your EDLs.

  1. Convert existing EDL lists to indicators in Cortex XSOAR. This can be done automatically:
    1. Extract your EDL as a text file from the web server it’s currently hosted on.
    2. Upload it as a file to the Playground and use the ExtractIndicatorsFromTextFile automation. e.g., !ExtractIndicatorsFromTextFile entryID=<entry_id>
  2. Go to the Indicators page in Cortex XSOAR 6.13, Cortex XSOAR 8 Cloud, Cortex XSOAR 8.7 On-prem, or Cortex XSIAM to find all of the indicators you extracted from the text file.
  3. If needed, batch select the indicators and add a tag to the indicators you want to host as a specific EDL. Use this tag in the Indicator Query integration parameter when configuring the integration. For example, if you want to create an allowed list of indicators and a blocked list of indicators.
  4. Edit the EDL object on the PAN-OS device to pull from the Export Indicators Service (PAN-OS EDL Service) instance, as explained in Access the Export Indicators Service by Instance Name (HTTPS). You can edit the EDL object using the panorama-edit-edl command in the Palo Alto Networks PAN-OS integration.
  5. Commit and push the configuration from the Panorama device to its respective Firewalls using the PAN-OS Commit Configuration playbook.
  6. If you have a deployment with 100 firewalls or more, we recommend using your Panorama device and creating an EDL object there, which will be populated from the PAN-OS EDL Service. Then push the EDL object to the respective firewalls.
  7. Follow the instructions in the rest of this guide to make sure that the PAN-OS device is connected to the EDL service.

Important Notes:

  • EDL uses three consecutive ports: the configured Listen Port, port + 1, and port + 2. NGINX listens on the configured port (public), the Python process listens on port + 1, and NGINX uses port + 2 internally for its fail-fast cache fetch tier. For example, if configured for port 9009, ports 9009, 9010, and 9011 must all be free. Ensure no other instance uses a Listen Port within 2 of another, or an [Errno 98] Address in use error will occur. When running without --network=host, ports + 1 and + 2 are not exposed to the machine.
  • If you frequently use different queries for the same EDL instance through the q inline argument, use separate EDL instances (one per query) and set a default query for each to improve performance.
  • When using the q inline argument, the number of exported indicators is limited to 100,000 for performance reasons. To export more than 100,000 indicators, create a new integration instance with the desired Indicator Query and List Size.
  • Note: After successfully configuring an instance, clicking the ‘Test’ button again may display a failure because the system incorrectly assumes the port is in use. Despite this message, the instance continues to function correctly.
  • When the integration is configured with Update list on demand only, the indicator list refreshes based on the value set in the Refresh Rate parameter.

Use Cases


  1. Export a list of malicious IPs to block via a firewall.
  2. Export a list of indicators to a service such as Splunk, using a supported output format.
  3. Generate feeds to be used on PAN-OS as External Dynamic Lists.
  4. Create External Dynamic Lists (EDLs) of the IP addresses, URLs, and domains used by ransomware, known APT groups, and active malware campaigns for tracking in AutoFocus.
  5. Create External Dynamic Lists to track IPs and URLs commonly used by Microsoft Office365 or CDNs and cloud services, or used as tor exit nodes.

Configure Generic Export Indicators Service on Cortex XSOAR


  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for Generic Export Indicators Service.
  3. Click Add instance to create and configure a new integration instance.
Parameter Description Required
Update list on demand only Enabling this prevents automatic list refresh. False
Indicator Query The query to run to update the indicators list. To view expected results, run the following command from the Cortex XSOAR CLI !findIndicators query=<your query> (Field names in your query should match the Machine name (Cortex XSOAR 6.13) or Machine name (Cortex XSOAR 8 Cloud) or Machine name (Cortex XSOAR 8.7 On-prem) for each field.) False
Outbound Format The format of the exported list. True
Exported Fields For use with JSON and CSV formats - select specific Cortex XSOAR fields to export. If given the value ‘all’ - all Cortex XSOAR fields are exported. If empty - only value and type are exported. False
List Size Maximum number of items in the list. True
Refresh Rate How often to refresh the list (e.g., less than 1 minute, 5 minutes, 12 hours, 7 days, 3 months, 1 year). For performance reasons, we do not recommend setting this value at less than 1 minute. False
Listen Port Runs the service on this port from within Cortex XSOAR. Requires a unique port for each long-running integration instance. Do not use the same port for multiple instances.
Note: If you click the test button more than once, a failure may occur mistakenly indicating that the port is already in use.
(For Cortex XSOAR 8 Cloud and Cortex XSIAM) If using an engine, you must enter a Listen Port. If not using an engine, do not enter a Listen Port and an unused port for the Generic Export Indicators Service will automatically be generated when the instance is saved.
True
Certificate (Required for HTTPS) For use with HTTPS - the certificate that the service should use.
Supported for Cortex XSOAR On-prem (6.x or 8) or when using an engine. Cortex XSOAR 8 Cloud tenants and Cortex XSIAM tenants do not support custom certificates.
False
Private Key (Required for HTTPS) For use with HTTPS - the private key that the service should use.
Supported for Cortex XSOAR On-prem (6.x or 8) or when using an engine. Cortex XSOAR 8 Cloud tenants and Cortex XSIAM tenants do not support private keys.
False
Username Uses basic authentication for accessing the list. If empty, no authentication is enforced. (For Cortex XSOAR 6.x) False
(For Cortex XSOAR 8 and Cortex XSIAM) Optional for engines, otherwise mandatory.
Password Uses basic authentication for accessing the list. If empty, no authentication is enforced. (For Cortex XSOAR 6.x) False
(For Cortex XSOAR 8 and Cortex XSIAM) Optional for engines, otherwise mandatory.
Add comment to empty list If selected, add to an empty list the comment “# Empty list”. False
Strip ports from URLs If selected, ports in URLs are removed. For example, ‘www.example.com:9999/path’ becomes ‘www.example.com/path’. False
Strip protocols from URLs If selected, strips the protocol from URLs (http/https)/. False
Truncate URL length If selected, URLs are truncated to no more than 254 characters. False
Prepend string to list String to add to beginning of published list. Supports newline characters (\n). False
Append string to list String to add to end of published list. Supports newline characters (\n). False
IP Collapsing For use with PAN-OS (text) format - collapse method for IPs (none, range, CIDR). False
PAN-OS: drop invalid URL entries For use with PAN-OS (text) format - if selected, any URL entry that is not compliant with PAN-OS URL format is dropped instead of rewritten. False
McAfee Gateway: Indicator List Type For use with McAfee Web Gateway format - set the indicator list type. False
Symantec ProxySG: Default Category For use with Symantec ProxySG format - set the default category for the output. False
Symantec ProxySG: Listed Categories For use with Symantec ProxySG format - set the categories that should be listed in the output. If not set, lists all existing categories. False
Show CSV formats as Text If selected, CSV format appears in a textual webpage instead of initiating a file download. False
XSOAR Indicator Page Size Internal page size used when querying Cortex XSOAR for the indicators. False
Maximum Size of CIDR Block (by mask bit) CIDRs with a lower network prefix bits number are not included. For example - if the number is 8, then 0.0.0.0/2 is excluded from the list. False
Exclude top level domainGlobs Option to remove top level domainGlobs from the list. For example - *.com. False
Advanced: NGINX Global Directives NGINX global directives to be passed on the command line using the -g option. Each directive should end with ;. For example: worker_processes 4; timer_resolution 100ms;. Advanced configuration to be used only if instructed by Cortex XSOAR Support. False
Advanced: NGINX Server Conf NGINX server configuration to be used instead of the default NGINX_SERVER_CONF used in the integration code. Advanced configuration to be used only if instructed by Cortex XSOAR Support. False
Cache Lock Timeout (Deprecated) The parameter is deprecated and no longer used. Cache locking was replaced by a two-tier fail-fast design that rejects excess concurrent cache-building requests instead of queuing them. False
Cache Lock Age (Deprecated) The parameter is deprecated and no longer used. Cache locking was replaced by a two-tier fail-fast design that rejects excess concurrent cache-building requests instead of queuing them. False
Cache 404 TTL The TTL for 404 responses in the cache. False
Cache Default TTL The default TTL for responses in the cache. False
Advanced: NGINX Read Timeout NGNIX read timeout in seconds. False
Advanced: use legacy queries When enabled, the integration queries the server using full queries. Advanced configuration to be used only if instructed by Cortex XSOAR Support, or you’ve encountered log errors in the form of: ‘msgpack: invalid code.’ False

Safeguards

There are two integrations parameters used as safeguards: Maximum CIDR network prefix bits size and Exclude top level domainGlobs.

These parameters prevent the integration from incorrectly inserting unwanted TLDs or a CIDR with a too wide range.

The default value for Maximum CIDR network prefix bits size is 8, which means that CIDRs with a lower network prefix bits number are not included (such as 0.0.0.0/2).

The default value for Exclude top level domainGlobs is off. If enabled, the exported list does not hold indicators such as *.com, *.co.uk, *.org and other top level domains.

Unique Behaviors

domainGlob

When parsing domainGlob indicator types, the parser creates two different inputs (usually how DNS Firewalls work). For example if the domainGlob *.bad.com is parsed, it outputs two lines to the list:

  1. *.bad.com
  2. bad.com

The DNS also blocks bad.com which does not happen if only *.bad.com is listed.

IP Collapsing

When IP Collapsing is enabled, duplications of IP ranges are removed. For example if there are 2 CIDRs in the list - 1.2.3.0/8 and 1.2.3.0/16 - only 1.2.3.0/8 will be included in the exported list.

Append string to list

Option to add a list of constant values to the exported list.
Expected value is a string, supports newline characters (\n).

PAN-OS: drop invalid URL entries

When PAN-OS: drop invalid URL entries is enabled, any URL entry that is not compliant with PAN-OS URL format is dropped instead of rewritten.

Exported Fields

This applies to the JSON and CSV formats - select specific Cortex XSOAR fields to export.
If given the value all - all of Cortex XSOAR’s available fields will be exported. If set to empty - only the indicator value and type will be exported.

Optional system fields are:

  • id
  • modified
  • sortValues
  • comments
  • indicator
  • value
  • source
  • sourceInstances
  • sourceBrands
  • investigationIDs
  • lastSeen
  • firstSeen
  • lastSeenEntryID
  • firstSeenEntryID
  • CustomFields
  • tags
  • expirationStatus
  • expirationSource
  • calculatedTime
  • lastReputationRun
  • modifiedTime
  • aggregatedReliability
  • communitynotes

In addition to the system fields, you can also search for custom fields.
In order to get the list of all available fields to search by, you can configure the Exported Fields parameter with the all option and check the list returned.

Access the Export Indicators Service by Instance Name (HTTPS) - For Cortex XSOAR 6.x only

Note:
By default, the route is open without security hardening and might expose you to network risks. Cortex XSOAR recommends that you use credentials to connect to the integration.

To access the Export Indicators service by instance name, make sure Instance execute external is enabled.

  1. Navigate to Settings > About > Troubleshooting.
  2. In the Server Configuration section, verify that the instance.execute.external key is set to true. If this key does not exist, click + Add Server Configuration and add the instance.execute.external and set the value to true. See this documentation for further information.
  3. In a web browser, go to:
    https://<xsoar_address>/instance/execute/<instance_name>

Set up Authentication

EDLs running on tenants in Cortex XSOAR 8 Cloud or Cortex XSIAM require basic authentication. EDLs running on engines do not require basic authentication, but it is recommended.
For Cortex XSOAR On-prem (6.x or 8) or when using engines, you can set up authentication using custom certificates. For more information on setting up a custom certificate for Cortex XSOAR 8 On-prem, see HTTPS with a signed certificate.

Access EDLs on Cortex XSOAR 8 Cloud and On-prem and Cortex XSIAM

Note:
For Cortex XSOAR 8 On-prem, you need to add the ext- FQDN DNS record to map the Cortex XSOAR DNS name to the external IP address.
For example, ext-xsoar.mycompany.com.

For Cortex XSOAR 8 Cloud, Cortex XSOAR 8 On-prem and Cortex XSIAM, you can only access the Export Indicators Service using a third-party tool such as curl.

  • For Cortex XSOAR, if the integration is configured to run on a tenant, use https://ext-<cortex-xsoar-address>/xsoar/instance/execute/<instance-name>.

    For example: curl -v -u user:pass https://ext-mytenant.paloaltonetworks.com/xsoar/instance/execute/edl_instance_01?q=type:ip

  • For Cortex XSIAM, if the integration is configured to run on a tenant, you must replace the xdr in the URL with crtx. For example, if the tenant URL is https://platform-test.xdr-qa2-uat.us.example.com/, use https://ext-platform-test.crtx-qa2-uat.us.example.com/.

    For example: curl -v -u user:pass https://ext-platform-test.crtx-qa2-uat.us.example.com/xsoar/instance/execute/edl_instance_01?q=type:ip

  • If the integration is configured to run on an engine, use http://<engine-address>:<integration listen port>.

    For example: curl -v -u user:pass http://<engine_address>:<listen_port>?n=50

URL Inline Arguments

Use the following arguments in the URL to change the request:

Argument Name Description Example
n The maximum number of entries in the output. If no value is provided, uses the value specified in the List Size parameter configured in the instance configuration. https://{server_host}/instance/execute/{instance_name}?n=50
s The starting entry index from which to export the indicators when index 0 is the first position. https://{server_host}/instance/execute/{instance_name}?s=10&n=50
v The output format. Supports PAN-OS (text), CSV, JSON, mwg and proxysg (alias: bluecoat). https://{server_host}/instance/execute/{instance_name}?v=JSON
q The query used to retrieve indicators from the system. If you are using this argument, no more than 100,000 can be exported through the EDL. https://{server_host}/instance/execute/{instance_name}?q="type:ip and sourceBrand:my_source"
t Only with mwg format. The type indicated on the top of the exported list. Supports: string, applcontrol, dimension, category, ip, mediatype, number and regex. https://{server_host}/instance/execute/{instance_name}?v=mwg&t=ip
sp If set, strips ports off URLs. https://{server_host}/instance/execute/{instance_name}?v=PAN-OS (text)&sp
pr If set, strips protocol off URLs. https://{server_host}/instance/execute/{instance_name}?v=text&pr
di Only with PAN-OS (text) format. If set, ignores URLs which are not compliant with PAN-OS URL format instead of rewriting the URLs. https://{server_host}/instance/execute/{instance_name}?v=PAN-OS (text)&di
tr Only with PAN-OS (text)Whether to collapse IPs. 0 - to not collapse, 1 - collapse to ranges or 2 - collapse to CIDRs https://{server_host}/instance/execute/{instance_name}?q="type:ip and sourceBrand:my_source"&tr=1
cd Only with proxysg format. The default category for the exported indicators. https://{server_host}/instance/execute/{instance_name}?v=proxysg&cd=default_category
ca Only with proxysg format. The categories which are exported. Indicators not falling into these categories are classified as the default category. https://{server_host}/instance/execute/{instance_name}?v=proxysg&ca=category1,category2
tx Whether to output CSV format as textual web pages. https://{server_host}/instance/execute/{instance_name}?v=CSV&tx
mc Configure max CIDR size. https://{server_host}/instance/execute/{instance_name}?mc=10
nt Configure whether to exclude top level domainGlobs. https://{server_host}/instance/execute/{instance_name}?nt=true

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.

export-indicators-list-update


Updates values stored in the List (only available On-Demand).

Base Command

export-indicators-list-update

Input
Argument Name Description Required
query The query to run to update the indicators list. To view expected results, run the following command from the Cortex XSOAR CLI !findIndicators query=<your query>. An empty value may load unwanted indicators. Required
format The output format. Optional
edl_size The maximum number of entries in the output. If no value is provided, uses the value specified in the List Size parameter configured in the instance configuration. Optional
print_indicators If set to true, prints the indicators that were saved to the export indicators service. Required
mwg_type For use with McAfee Web Gateway format to indicate the list type. Optional
url_port_stripping If true, strips the port off URLs. Optional
url_protocol_stripping If true, strips the port off URLs. Optional
drop_invalids For use with PAN-OS (text) format - if checked any URL entry which is not compliant with PAN-OS EDL URL format is dropped instead of rewritten. Optional
category_attribute For use with Symantec ProxySG format - set the categories that should be listed in the output. If not set lists all existing categories. Optional
category_default For use with Symantec ProxySG format - set the default category for the output. Optional
collapse_ips For use with PAN-OS (text) format - Whether to collapse IPs, and if so - to ranges or CIDRs Optional
csv_text If true, outputs csv format as textual web pages Optional
add_comment_if_empty If selected, add to an empty List the comment “# Empty List”. Optional
Context Output

There is no context output for this command.

Command Example

!export-indicators-list-update=type:IP edl_size=2

Human Readable Output

‘EDL will be updated the next time you access it’

Troubleshooting

  • Indicators that are passed through the integration undergo formatting and deduplication, which may lead to an apparent loss of indicators.
    For instance, enabling the Strip ports from URLs option may cause two URLs that are similar but use different ports to be merged into a single indicator after formatting, resulting in the removal of one of them as a duplicate.
  • In case all fields are selected, there is a potential memory issue when dealing with CSV or JSON format files that exceed 150,000 entries.

Custom HTTP Headers

The response from EDL’s endpoint includes custom headers, starting with the X-EDL prefix, that can be used for debugging purposes.
The headers are:

  • X-EDL-Created - The date and time the response was created.
  • X-EDL-Query-Time-Secs - The time it took to execute the query and format the response.
  • X-EDL-Size - The number of indicators returned in the response.
  • X-EDL-Origin-Size - The number of indicators originally fetched before formatting and deduplication.

Execution Time

  • 10,000 indicators can take 10-20 seconds.
  • 100,000 indicators can take up to 1-3 minutes.
  • 1,000,000 indicators can take over half an hour.

In 5 minutes (the default timeout of the integration) the integration can export between 200,000 to 400,000 indicators,
depending on the load of the server, the existing indicators in the server, and the query used.

The NGINX Read Timeout can be set to increase the timeout.

Troubleshooting

504 Gateway error

  1. Increase the NGINX Read Timeout in the instance configuration (for 1,000,000 indicators, it is recommended to increase the timeout up to 1 hour).
  2. If the issue persists, try to increase the Load Balancer timeout through the Devops team. For 800,000 indicators, it is recommended to increase the timeout up to 1 hour (depending on the indicator query).

429 Too Many Requests error

NGINX prevents concurrent builds of the same cache entry. If multiple requests for the same cache entry (matching the URL and parameters, like q) arrive simultaneously, NGINX builds the entry for the first request and rejects the others with an HTTP 429 Too Many Requests rather than queuing them. Requests for different entries are still processed in parallel. If a request triggers a refresh of an existing cache entry, the previous data is served (HTTP 200) with no 429.

This is expected behavior. Retry the request after a short delay; once the initial build finishes populating the cache, retries are served from the cache (HTTP 200).

Deleted or expired indicators showing in EDL export

Append expirationStatus:active to the end of the query.

EDL Log

To view logs concerning the creation of the indicator list and its current status, add the /log suffix to the list URL.

For Cortex XSOAR Cloud, use https://ext-<cortex-xsoar-address>/xsoar/instance/execute/<instance-name>/log

For Cortex XSOAR On-prem, use https://*<xsoar_address>*/instance/execute/*<instance_name>*/log

For Cortex XSIAM, use https://ext-<cortex-xsiam-address>/xsoar/instance/execute/<instance-name>/log and replace the xdr in the URL with crtx.

Configuration parameters

  • on_demand — Update list on demand only
  • indicators_query — Indicator Query
  • format — Outbound Format
  • fields_filter — Exported Fields
  • edl_size — List Size
  • cache_refresh_rate — Refresh Rate
  • certificate — Certificate (Required for HTTPS)
  • key — Private Key (Required for HTTPS)
  • cache_lock_timeout — Cache Lock Timeout (Deprecated)
  • cache_lock_age — Cache Lock Age (Deprecated)
  • cache_404_ttl — Cache 404 TTL
  • cache_default_ttl — Cache Default TTL
  • credentials — Username
  • longRunningPort — Listen Port (required)
  • add_comment_if_empty — Add comment to empty list
  • url_port_stripping — Strip ports from URLs
  • url_protocol_stripping — Strip protocols from URLs
  • url_truncate — Truncate URL length
  • enforce_ascii — Enforce ASCII only
  • prepend_string — Prepend string to list
  • append_string — Append string to list
  • collapse_ips — IP Collapsing
  • maximum_cidr_size — Maximum Size of CIDR Block (by mask bit)
  • no_wildcard_tld — Exclude top level domainGlobs
  • drop_invalids — PAN-OS: drop invalid URL entries
  • hsts_header — Add HSTS header
  • mwg_type — McAfee Gateway: Indicator List Type
  • category_default — Symantec ProxySG: Default Category
  • category_attribute — Symantec ProxySG: Listed Categories
  • csv_text — Show CSV format as Text
  • page_size — XSOAR Indicator Page Size
  • longRunning — Long Running Instance
  • nginx_global_directives — NGINX Global Directives
  • nginx_server_conf — NGINX Server Conf
  • timeout — NGINX Read Timeout
  • use_legacy_query — Advanced: Use Legacy Queries
  • extensive_logging — Extensive Logging

Commands (2)

  • edl-update Deprecated

    Updates values stored in the EDL (only available On-Demand).

  • export-indicators-list-update

    Updates values stored in the List (only available On-Demand).

import glob
import hashlib
import ipaddress
import os
import re
import tempfile
import uuid
import zipfile
from base64 import b64decode
from collections.abc import Callable, Iterable
from datetime import datetime
from enum import Enum
from math import ceil
from pathlib import Path
from typing import IO

import demistomock as demisto  # noqa: F401
import tldextract
import urllib3
from CommonServerPython import *  # noqa: F401
from flask import Flask, Response, request, send_file
from netaddr import IPNetwork, IPRange, IPSet

# Disable insecure warnings
urllib3.disable_warnings()

""" GLOBAL VARIABLES """
INTEGRATION_NAME: str = "Generic Export Indicators service"
PAGE_SIZE: int = 2000
PAN_OS_MAX_URL_LEN = 255
APP: Flask = Flask("demisto-edl")
EDL_LIMIT_ERR_MSG: str = "Please provide a valid integer for List Size"
EDL_CIDR_SIZR_MSG: str = "Please provide a valid integer for CIDR size"
EDL_OFFSET_ERR_MSG: str = "Please provide a valid integer for Starting Index"
EDL_COLLAPSE_ERR_MSG: str = "The Collapse parameter can only get the following: 0 - none, 1 - range, 2 - CIDR"
EDL_MISSING_REFRESH_ERR_MSG: str = (
    'Refresh Rate must be "number date_range_unit", examples: (2 hours, 4 minutes, 6 months, 1 day, etc.)'
)
EDL_FORMAT_ERR_MSG: str = "Please provide a valid format from: text, json, csv, mgw and proxysg"
EDL_MWG_TYPE_ERR_MSG: str = (
    "The McAFee Web Gateway type can only be one of the following: string,"
    " applcontrol, dimension, category, ip, mediatype, number, regex"
)
EDL_NO_URLS_IN_PROXYSG_FORMAT = "ProxySG format only outputs URLs - no URLs found in the current query"
MAX_LIST_SIZE_WITH_URL_QUERY = 100000

EDL_ON_DEMAND_KEY: str = "UpdateEDL"
EDL_ON_DEMAND_CACHE_PATH: str = ""
EDL_FULL_LOG_PATH: str = f"full_log_{demisto.uniqueFile()}"
EDL_FULL_LOG_PATH_WIP: str = f"wip_log_{demisto.uniqueFile()}"
LOGS_ZIP_FILE_PREFIX: str = "log_download"
EDL_ON_DEMAND_CACHE_ORIGINAL_SIZE: int = 0
EDL_SEARCH_LOOP_LIMIT: int = 10
MAX_DISPLAY_LOG_FILE_SIZE = 100000
LARGE_LOG_DISPLAY_MSG = "# Log exceeds max size. Refresh to download as file."

""" REFORMATTING REGEXES """
_PROTOCOL_REMOVAL = re.compile("^(?:[a-z]+:)*//")
_PORT_REMOVAL = re.compile(r"^((?:[a-z]+:)*//([a-z0-9\-\.]+)|([a-z0-9\-\.]+))(?:\:[0-9]+)*")
_URL_WITHOUT_PORT = r"\g<1>"
_INVALID_TOKEN_REMOVAL = re.compile(r"(?:[^\./+=\?&]+\*[^\./+=\?&]*)|(?:[^\./+=\?&]*\*[^\./+=\?&]+)")
_BROAD_PATTERN = re.compile(r"^(?:\*\.)+[a-zA-Z]+(?::[0-9]+)?$")

DONT_COLLAPSE = "Don't Collapse"
COLLAPSE_TO_CIDR = "To CIDRS"
COLLAPSE_TO_RANGES = "To Ranges"

MAXIMUM_CIDR_SIZE_DEFAULT = 8

MIMETYPE_JSON_SEQ: str = "application/json-seq"
MIMETYPE_JSON: str = "application/json"
MIMETYPE_CSV: str = "text/csv"
MIMETYPE_TEXT: str = "text/plain"

FORMAT_CSV: str = "CSV"
FORMAT_TEXT: str = "PAN-OS (text)"
FORMAT_JSON: str = "JSON"
FORMAT_ARG_MWG = "mwg"
FORMAT_ARG_BLUECOAT = "bluecoat"
FORMAT_ARG_PROXYSG = "proxysg"
FORMAT_MWG: str = "McAfee Web Gateway"
FORMAT_PROXYSG: str = "Symantec ProxySG"

MWG_TYPE_OPTIONS = ["string", "applcontrol", "dimension", "category", "ip", "mediatype", "number", "regex"]

INCREASE_LIMIT = 1.1

EXTENSIVE_LOGGING: bool = False


class IndicatorAction(Enum):
    ADDED = "Added"
    MODIFIED = "Modified"
    DROPPED = "Dropped"


"""Request Arguments Class"""


def debug_function(func):
    def wrapper(*args, **kwargs):
        if EXTENSIVE_LOGGING:
            demisto.debug(f"edl: Entering function {func.__name__}")
        results = func(*args, **kwargs)
        if EXTENSIVE_LOGGING:
            demisto.debug(f"edl: Exiting function {func.__name__}")
        return results

    return wrapper


class RequestArguments:
    CTX_QUERY_KEY = "last_query"
    CTX_OUT_FORMAT = "out_format"
    CTX_LIMIT_KEY = "last_limit"
    CTX_OFFSET_KEY = "last_offset"
    CTX_INVALIDS_KEY = "drop_invalids"
    CTX_PORT_STRIP_KEY = "url_port_stripping"
    CTX_COLLAPSE_IPS_KEY = "collapse_ips"
    CTX_EMPTY_EDL_COMMENT_KEY = "add_comment_if_empty"
    CTX_MWG_TYPE = "mwg_type"
    CTX_CATEGORY_DEFAULT = "bc_category"
    CTX_CATEGORY_ATTRIBUTE = "category_attribute"
    CTX_FIELDS_TO_PRESENT = "fields_to_present"
    CTX_CSV_TEXT = "csv_text"
    CTX_PROTOCOL_STRIP_KEY = "url_protocol_stripping"
    CTX_URL_TRUNCATE_KEY = "url_truncate"
    CTX_MAXIMUM_CIDR = "maximum_cidr_size"
    CTX_NO_TLD = "no_wildcard_tld"

    FILTER_FIELDS_ON_FORMAT_TEXT = "name,type"
    FILTER_FIELDS_ON_FORMAT_MWG = "name,type,sourceBrands"
    FILTER_FIELDS_ON_FORMAT_PROXYSG = "name,type,proxysgcategory"
    FILTER_FIELDS_ON_FORMAT_CSV = "name,type"
    FILTER_FIELDS_ON_FORMAT_JSON = "name,type"

    def __init__(
        self,
        query: str = "",
        out_format: str = FORMAT_TEXT,
        limit: int = 10000,
        offset: int = 0,
        url_port_stripping: bool = False,
        drop_invalids: bool = False,
        collapse_ips: str = DONT_COLLAPSE,
        add_comment_if_empty: bool = True,
        mwg_type: str = "string",
        category_default: str = "bc_category",
        category_attribute: Optional[str] = None,
        fields_to_present: str = "",
        csv_text: bool = False,
        url_protocol_stripping: bool = False,
        url_truncate: bool = False,
        maximum_cidr_size: int = MAXIMUM_CIDR_SIZE_DEFAULT,
        no_wildcard_tld: bool = False,
    ):
        self.query = query
        self.out_format = out_format
        self.limit = try_parse_integer(limit, EDL_LIMIT_ERR_MSG)
        self.offset = try_parse_integer(offset, EDL_OFFSET_ERR_MSG)
        self.url_port_stripping = url_port_stripping
        self.url_protocol_stripping = url_protocol_stripping
        self.drop_invalids = drop_invalids
        self.collapse_ips = collapse_ips
        self.add_comment_if_empty = add_comment_if_empty
        self.mwg_type = mwg_type
        self.category_default = category_default
        self.category_attribute = []  # type:List
        self.fields_to_present = self.get_fields_to_present(fields_to_present)
        self.csv_text = csv_text
        self.url_truncate = url_truncate
        self.maximum_cidr_size = maximum_cidr_size
        self.no_wildcard_tld = no_wildcard_tld

        if category_attribute is not None:
            category_attribute_list = argToList(category_attribute)

            if len(category_attribute_list) != 1 or "" not in category_attribute_list:
                self.category_attribute = category_attribute_list

    def to_context_json(self):
        return {
            self.CTX_QUERY_KEY: self.query,
            self.CTX_OUT_FORMAT: self.out_format,
            self.CTX_LIMIT_KEY: self.limit,
            self.CTX_OFFSET_KEY: self.offset,
            self.CTX_INVALIDS_KEY: self.drop_invalids,
            self.CTX_PORT_STRIP_KEY: self.url_port_stripping,
            self.CTX_COLLAPSE_IPS_KEY: self.collapse_ips,
            self.CTX_EMPTY_EDL_COMMENT_KEY: self.add_comment_if_empty,
            self.CTX_MWG_TYPE: self.mwg_type,
            self.CTX_CATEGORY_DEFAULT: self.category_default,
            self.CTX_CATEGORY_ATTRIBUTE: self.category_attribute,
            self.CTX_FIELDS_TO_PRESENT: self.fields_to_present,
            self.CTX_CSV_TEXT: self.csv_text,
            self.CTX_PROTOCOL_STRIP_KEY: self.url_protocol_stripping,
            self.CTX_URL_TRUNCATE_KEY: self.url_truncate,
            self.CTX_MAXIMUM_CIDR: self.maximum_cidr_size,
            self.CTX_NO_TLD: self.no_wildcard_tld,
        }

    @classmethod
    def from_context_json(cls, ctx_dict):
        """Returns an initiated instance of the class from a json"""
        return cls(
            **assign_params(
                query=ctx_dict.get(cls.CTX_QUERY_KEY),
                out_format=ctx_dict.get(cls.CTX_OUT_FORMAT),
                limit=ctx_dict.get(cls.CTX_LIMIT_KEY),
                offset=ctx_dict.get(cls.CTX_OFFSET_KEY),
                drop_invalids=ctx_dict.get(cls.CTX_INVALIDS_KEY),
                url_port_stripping=ctx_dict.get(cls.CTX_PORT_STRIP_KEY),
                collapse_ips=ctx_dict.get(cls.CTX_COLLAPSE_IPS_KEY),
                add_comment_if_empty=ctx_dict.get(cls.CTX_EMPTY_EDL_COMMENT_KEY),
                mwg_type=ctx_dict.get(cls.CTX_MWG_TYPE),
                category_default=ctx_dict.get(cls.CTX_CATEGORY_DEFAULT),
                category_attributeself=ctx_dict.get(cls.CTX_CATEGORY_ATTRIBUTE),
                fields_to_present=ctx_dict.get(cls.CTX_FIELDS_TO_PRESENT),
                csv_text=ctx_dict.get(cls.CTX_CSV_TEXT),
                url_protocol_stripping=ctx_dict.get(cls.CTX_PROTOCOL_STRIP_KEY),
                url_truncate=ctx_dict.get(cls.CTX_URL_TRUNCATE_KEY),
                maximum_cidr_size=ctx_dict.get(cls.CTX_MAXIMUM_CIDR),
                no_wildcard_tld=ctx_dict.get(cls.CTX_NO_TLD),
            )
        )

    def get_fields_to_present(self, fields_to_present: str) -> str:
        # based on func ToIoC https://github.com/demisto/server/blob/master/domain/insight.go

        # Fixes legacy query mode silently lost after the first refresh because
        # `get_fields_to_present("")` returned "name,type" instead of ""
        if fields_to_present == "use_legacy_query" or (not fields_to_present and self.out_format == FORMAT_TEXT):
            return ""

        fields_for_format = {
            FORMAT_TEXT: self.FILTER_FIELDS_ON_FORMAT_TEXT,
            FORMAT_CSV: self.FILTER_FIELDS_ON_FORMAT_CSV,
            FORMAT_JSON: self.FILTER_FIELDS_ON_FORMAT_JSON,
            FORMAT_MWG: self.FILTER_FIELDS_ON_FORMAT_MWG,
            FORMAT_PROXYSG: self.FILTER_FIELDS_ON_FORMAT_PROXYSG,
        }
        if self.out_format in [FORMAT_CSV, FORMAT_JSON] and fields_to_present:
            if "all" in argToList(fields_to_present):
                return ""
            else:
                # replace "value" to "name"
                list_fields = argToList(fields_to_present)
                if "value" in list_fields:
                    list_fields[list_fields.index("value")] = "name"
                    fields_to_present = ",".join(list_fields)
                return fields_to_present

        return fields_for_format.get(self.out_format, self.FILTER_FIELDS_ON_FORMAT_TEXT)


""" HELPER FUNCTIONS """


def iterable_to_str(iterable: Iterable, delimiter: str = "\n") -> str:
    """
    Transforms an iterable object to an str, with a custom delimiter between each item
    """
    str_res = ""
    if iterable:
        try:
            iter(iterable)
        except TypeError:
            raise DemistoException(f"non iterable object provided to iterable_to_str: {iterable}")
        str_res = delimiter.join(map(str, iterable))
    return str_res


@debug_function
def log_iocs_file_data(formatted_indicators: str, max_length: int = 100) -> None:
    """Prints a debug log of the first `max_length` characters in the formatted indicators data.

    Args:
        formatted_indicators (str): The IOCs formatted data.
        max_length (int, optional): max # of chars to print. Defaults to 100.
    """
    if formatted_indicators:
        truncated_data = formatted_indicators[:max_length]
        demisto.debug(f"Formatted IOC data (first {max_length} characters):\n{truncated_data}")
    else:
        demisto.debug("No data from IOC search.")


@debug_function
def create_new_edl(request_args: RequestArguments) -> tuple[str, int, dict]:
    """
    Retrieves indicators using the IndicatorsSearcher and formats them according to the request arguments.
    Applies optional cleanup (invalid removal, IP collapsing) and collects statistics on the changes.

    Args:
        request_args (RequestArguments): User-defined settings for formatting, filtering, and pagination.

    Returns:
        tuple[str, int, dict]:
            - str: A formatted string of indicators, ready to be returned in an EDL (one per line).
            - int: The number of original indicators fetched before formatting.
            - dict: Logging statistics about actions taken on indicators (added, dropped, modified).
    """
    limit = request_args.offset + request_args.limit
    offset = request_args.offset
    indicator_searcher = IndicatorsSearcher(
        filter_fields=request_args.fields_to_present, query=request_args.query, size=PAGE_SIZE, limit=limit
    )
    demisto.debug(f"Creating a new EDL file in {request_args.out_format} format")
    formatted_indicators = ""
    new_log_stats = {}
    if request_args.out_format == FORMAT_TEXT:
        if request_args.drop_invalids or request_args.collapse_ips != "Don't Collapse":
            # Because there may be illegal indicators or they may turn into cider, the limit is increased
            indicator_searcher.limit = int(limit * INCREASE_LIMIT)
        new_iocs_file, original_indicators_count = get_indicators_to_format(indicator_searcher, request_args)
        # we collect first all indicators because we need all ips to collapse_ips
        new_iocs_file, new_log_stats = create_text_out_format(new_iocs_file, request_args)
        new_iocs_file.seek(0)
        iocs_set = set()
        for count, line in enumerate(new_iocs_file):
            # continue searching iocs if 1) iocs was truncated or 2) got all available iocs
            if count + 1 > limit:
                break
            if count < offset:
                continue
            elif line not in iocs_set:
                iocs_set.add(line)
                formatted_indicators += line

        demisto.debug(f"Finished formatting ioc. Count after collapse: {len(iocs_set)}")

    else:
        new_iocs_file, original_indicators_count = get_indicators_to_format(indicator_searcher, request_args)
        new_iocs_file.seek(0)
        formatted_indicators = new_iocs_file.read()
    new_iocs_file.close()
    if EXTENSIVE_LOGGING:
        log_iocs_file_data(formatted_indicators)
    return formatted_indicators, original_indicators_count, new_log_stats


@debug_function
def replace_field_name_to_output_format(fields: str):
    """
    convert from the request name field to the name in the response from the server
    """
    fields_list = argToList(fields)
    new_list = []
    for field in fields_list:
        if field == "name":
            field = "value"
        elif field == "type":
            field = "indicator_type"
        new_list.append(field)
    return new_list


@debug_function
def get_indicators_to_format(indicator_searcher: IndicatorsSearcher, request_args: RequestArguments) -> tuple[IO | IO[str], int]:
    """
    Finds indicators using demisto.searchIndicators, and returns the indicators in file written in the requested format
    Parameters:
        indicator_searcher (IndicatorsSearcher): The indicator searcher used to look for indicators
        request_args (RequestArguments):  all the request arguments.
    Returns:
        tuple[IO | IO[str], int]: A tuple of indicators in the requested format (IO | IO[str]),
            and the total number of indicators found by indicator_searcher (int).
    """
    f = tempfile.TemporaryFile(mode="w+t")
    list_fields = replace_field_name_to_output_format(request_args.fields_to_present)
    headers_was_written = False
    files_by_category = {}  # type:Dict
    ioc_counter = 0
    # While iterating over a large number of indicators, the main thread can hold the stdout lock longer than
    # the default 60s timeout, causing the heartbeat thread to fail with "Timeout acquiring stdout lock".
    # Instead of disabling the heartbeat thread (which can make the server consider the container unresponsive),
    # we temporarily raise the stdout lock timeout to 10 minutes so the heartbeat keeps running safely, and
    # restore the original value afterwards. This attribute is dynamically injected by the server runtime; when
    # it is not present (e.g. older servers or unit tests) we skip the tweak entirely so this stays a true no-op.
    # It is not part of the demistomock type stub, so mypy's attr-defined check is suppressed on the assignments.
    stdout_lock_timeout_supported = hasattr(demisto, "_stdout_lock_timeout")
    original_stdout_lock_timeout = getattr(demisto, "_stdout_lock_timeout", 60)
    try:
        if stdout_lock_timeout_supported:
            demisto._stdout_lock_timeout = 600  # type: ignore[attr-defined]  # 10 minutes
            demisto.debug(
                f"Temporarily set demisto._stdout_lock_timeout to 600 seconds "
                f"(was {original_stdout_lock_timeout}) for the indicators iteration."
            )
        for ioc_res in indicator_searcher:
            fetched_iocs = ioc_res.get("iocs") or []
            for ioc in fetched_iocs:
                if EXTENSIVE_LOGGING:
                    demisto.debug(f"Parsing the following indicator: {ioc.get('value')}")

                ioc_counter += 1
                if request_args.out_format == FORMAT_PROXYSG:
                    files_by_category = create_proxysg_out_format(ioc, files_by_category, request_args)

                elif request_args.out_format == FORMAT_MWG:
                    f.write(create_mwg_out_format(ioc, request_args, headers_was_written))
                    headers_was_written = True

                elif request_args.out_format == FORMAT_JSON:
                    f.write(create_json_out_format(list_fields, ioc, request_args, headers_was_written))
                    headers_was_written = True

                elif request_args.out_format == FORMAT_TEXT:
                    # save only the value and type of each indicator
                    f.write(str(json.dumps({"value": ioc.get("value"), "indicator_type": ioc.get("indicator_type")})) + "\n")

                elif request_args.out_format == FORMAT_CSV:
                    f.write(create_csv_out_format(headers_was_written, list_fields, ioc, request_args))
                    headers_was_written = True
                if ioc_counter >= indicator_searcher.limit:
                    break

    except Exception as e:
        demisto.error(f"Error in parsing the indicators, error: {e!s}")
        # 429 error can only be raised when the Elasticsearch instance encountered an error
        if "[429] Failed with error" in str(e):
            version = demisto.demistoVersion()
            # NG + XSIAM can recover from a shutdown
            if version.get("platform") == "x2" or is_demisto_version_ge("8") or version.get("platform") == "unified_platform":
                raise SystemExit("Encountered issue in Elastic Search query. Restarting container and trying again.")
    finally:
        if stdout_lock_timeout_supported:
            demisto._stdout_lock_timeout = original_stdout_lock_timeout  # type: ignore[attr-defined]
            demisto.debug(f"Restored demisto._stdout_lock_timeout to {original_stdout_lock_timeout} seconds.")
    demisto.debug(f"Completed IOC search & format, found {ioc_counter} IOCs.")
    if request_args.out_format == FORMAT_JSON:
        f.write("]")
    elif request_args.out_format == FORMAT_PROXYSG:
        f = create_proxysg_all_category_out_format(f, files_by_category)
    return f, ioc_counter


@debug_function
def create_json_out_format(list_fields: List, indicator: dict, request_args: RequestArguments, not_first_call=True) -> str:
    """format the indicator to json format.

    Args:
        list_fields (list): the fields to return.
        indicator (dict): the indicator info
        request_args (RequestArguments): all the request arguments.
        not_first_call (bool): Indicates if this is the first call to the function.

    Returns:
        An indicator to add to the file in json format.
    """
    if (indicator_value := indicator.get("value")) and indicator.get("indicator_type") == "URL":
        indicator["value"] = url_handler(
            indicator_value, request_args.url_protocol_stripping, request_args.url_port_stripping, request_args.url_truncate
        )
    filtered_json = {}
    if list_fields:
        for field in list_fields:
            value = indicator.get(field) or indicator.get("CustomFields", {}).get(field)
            filtered_json[field] = value
        indicator = filtered_json
    if not_first_call:
        return ", " + json.dumps(indicator)
    return "[" + json.dumps(indicator)


@debug_function
def create_mwg_out_format(indicator: dict, request_args: RequestArguments, headers_was_writen: bool) -> str:
    """format the indicator to mwg format.

    Args:
        indicator (dict): the indicator info
        request_args (RequestArguments): Request Arguments
        headers_was_writen (bool): Whether if the headers was writen to the file.

    Returns:
        An indicator to add to the file in mwg format.
    """
    if (indicator_value := indicator.get("value")) and indicator.get("indicator_type") == "URL":
        indicator["value"] = url_handler(
            indicator_value, request_args.url_protocol_stripping, request_args.url_port_stripping, request_args.url_truncate
        )

    value = '"' + indicator.get("value", "") + '"'
    sources = indicator.get("sourceBrands")
    if sources:
        sources_string = '"' + ",".join(sources) + '"'
    else:
        sources_string = '"from CORTEX XSOAR"'

    if not headers_was_writen:
        mwg_type = request_args.mwg_type
        if isinstance(mwg_type, list):
            mwg_type = mwg_type[0]
        return "type=" + mwg_type + "\n" + value + " " + sources_string
    return "\n" + value + " " + sources_string


@debug_function
def create_proxysg_all_category_out_format(indicators_file: IO, files_by_category: dict):
    """write all indicators to file in proxysg format.

    Args:
        indicators_file (IO): the fields to return.
        files_by_category (dict): all indicators by category

    Returns:
        a file in proxysg format.
    """
    # the first time "define category" will be writen without a new line
    new_line = ""
    for category, category_file in files_by_category.items():
        indicators_file.write(f"{new_line}define category {category}\n")
        new_line = "\n"
        category_file.seek(0)
        indicators_file.write(category_file.read())
        category_file.close()
        indicators_file.write("end")

    return indicators_file


@debug_function
def create_proxysg_out_format(indicator: dict, files_by_category: dict, request_args: RequestArguments) -> dict:
    """format the indicator to proxysg.

    Args:
        indicator (dict): the indicator info
        files_by_category (list): a dict of the formatted indicators by category.
        request_args (RequestArguments): Request Arguments

    Returns:
        a dict of the formatted indicators by category.
    """
    if (indicator_value := indicator.get("value")) and indicator.get("indicator_type") in ["IP", "URL", "Domain", "DomainGlob"]:
        stripped_indicator = url_handler(
            indicator_value, request_args.url_protocol_stripping, request_args.url_port_stripping, request_args.url_truncate
        )
        indicator_proxysg_category = indicator.get("CustomFields", {}).get("proxysgcategory")
        # if a ProxySG Category is set and it is in the category_attribute list or that the attribute list is empty
        # than list add the indicator to it's category list
        if indicator_proxysg_category is not None and (
            indicator_proxysg_category in request_args.category_attribute or len(request_args.category_attribute) == 0
        ):
            # handle indicators in multiple categories
            if isinstance(indicator_proxysg_category, list):
                for category in indicator_proxysg_category:
                    files_by_category = add_indicator_to_category(stripped_indicator, category, files_by_category)
            else:
                files_by_category = add_indicator_to_category(stripped_indicator, indicator_proxysg_category, files_by_category)
        else:
            # if ProxySG Category is not set or does not exist in the category_attribute list
            files_by_category = add_indicator_to_category(stripped_indicator, request_args.category_default, files_by_category)

    return files_by_category


@debug_function
def add_indicator_to_category(indicator: str, category: str, files_by_category: dict):
    if category in files_by_category:
        files_by_category[category].write(indicator + "\n")

    else:
        files_by_category[category] = tempfile.TemporaryFile(mode="w+t")
        files_by_category[category].write(indicator + "\n")

    return files_by_category


@debug_function
def create_csv_out_format(headers_was_writen: bool, list_fields: List, ioc, request_args: RequestArguments):
    """format the ioc to csv format.

    Args:
        headers_was_writen (bool): Whether if the headers was writen to the file.
        list_fields (list): the fields to return.
        ioc (dict): the indicator info
        request_args (RequestArguments): all the request arguments.

    Returns:
        a one indicator to add to the file in csv format.
    """

    if (indicator_value := ioc.get("value")) and ioc.get("indicator_type") == "URL":
        ioc["value"] = url_handler(
            indicator_value, request_args.url_protocol_stripping, request_args.url_port_stripping, request_args.url_truncate
        )
    if not list_fields:
        values = list(ioc.values())
        if not headers_was_writen:
            headers = list(ioc.keys())
            headers_str = list_to_str(headers) + "\n"
            return headers_str + list_to_str(values, map_func=lambda val: f'"{val}"')
        return "\n" + list_to_str(values, map_func=lambda val: f'"{val}"')
    else:
        fields_value_list = []
        for field in list_fields:
            value = ioc.get(field) or ioc.get("CustomFields", {}).get(field)
            fields_value_list.append(value)
        if not headers_was_writen:
            headers_str = request_args.fields_to_present + "\n"
            return headers_str + list_to_str(fields_value_list, map_func=lambda val: f'"{val}"')
        return "\n" + list_to_str(fields_value_list, map_func=lambda val: f'"{val}"')


@debug_function
def ip_groups_to_cidrs(ip_range_groups: Iterable[IPNetwork]) -> set[str]:
    """Collapse IP groups list to CIDRs

    Args:
        ip_range_groups (Iterable[IPNetwork]): An iterable of IPNetwork objects representing connected IPs.

    Returns:
        set[str]: A set of CIDR strings (e.g., {'192.168.1.0/24', '10.0.0.1'})
    """
    ip_ranges: set[str] = set()
    for cidr in ip_range_groups:
        # handle single IPs
        # Use .size property instead of len() to avoid IndexError for very large ranges
        try:
            cidr_size = cidr.size
        except Exception as e:
            demisto.error(f"edl: Failed to collapse IP group {cidr} to CIDRs. Got error: {e}.")
            raise

        if cidr_size == 1:
            # CIDR with a single IP appears with "/32" suffix so handle them differently
            ip_ranges.add(str(cidr[0]))
            continue

        ip_ranges.add(str(cidr))

    return ip_ranges


@debug_function
def ip_groups_to_ranges(ip_range_groups: Iterable[IPRange]) -> set[str]:
    """Collapse IP groups to ranges.

    Args:
        ip_range_groups (Iterable[IPRange]): An iterable of IPRange objects representing connected IPs.

    Returns:
        set[str]: A set of IP range strings (e.g., {'192.168.1.1-192.168.1.255', '10.0.0.1'})
    """
    ip_ranges: set[str] = set()
    for group in ip_range_groups:
        # handle single IPs
        # Use .size property instead of len() to avoid IndexError for very large ranges
        try:
            group_size = group.size
        except Exception as e:
            demisto.error(f"edl: Failed to collapse IP group {group} to range. Got error: {e}.")
            raise

        if group_size == 1:
            ip_ranges.add(str(group[0]))
            continue

        ip_ranges.add(str(group))

    return ip_ranges


@debug_function
def ips_to_ranges(ips: Iterable, collapse_ips: str):
    """Collapse IPs to Ranges or CIDRs.

    Args:
        ips (Iterable): a group of IP strings.
        collapse_ips (str): Whether to collapse to Ranges or CIDRs.

    Returns:
        Set. a list to Ranges or CIDRs.
    """
    invalid_ips = []
    valid_ips = []

    for ip_or_cidr in ips:
        if is_valid_cidr(ip_or_cidr) or is_valid_ip(ip_or_cidr):
            valid_ips.append(ip_or_cidr)
        else:
            invalid_ips.append(ip_or_cidr)

    if collapse_ips == COLLAPSE_TO_RANGES:
        ips_range_groups = IPSet(valid_ips).iter_ipranges()
        collapsed_list = ip_groups_to_ranges(ips_range_groups)
    else:
        cidrs = IPSet(valid_ips).iter_cidrs()
        collapsed_list = ip_groups_to_cidrs(cidrs)

    collapsed_list.update(invalid_ips)
    return collapsed_list


def is_valid_ip(ip: str) -> bool:
    """
    Args:
        ip: IP address
    Returns: True if the string represents an IPv4 or an IPv6 address, false otherwise.
    """
    try:
        ipaddress.IPv4Address(ip)
        return True
    except ValueError:
        try:
            ipaddress.IPv6Address(ip)
            return True
        except ValueError:
            return False


def is_large_cidr(cidr: str, prefix_threshold: int):
    try:
        return IPNetwork(cidr).prefixlen < prefix_threshold
    except Exception as e:
        demisto.debug(str(e))
        return False


def is_valid_cidr(cidr: str) -> bool:
    """
    Args:
        cidr: CIDR string
    Returns: True if the string represents an IPv4 network or an IPv6 network, false otherwise.
    """
    if "/" not in cidr:
        return False
    try:
        ipaddress.IPv4Network(cidr, strict=False)
        return True
    except ValueError:
        try:
            ipaddress.IPv6Network(cidr, strict=False)
            return True
        except ValueError:
            return False


@debug_function
def list_to_str(inp_list: list, delimiter: str = ",", map_func: Callable = str) -> str:
    """
    Transforms a list to an str, with a custom delimiter between each list item
    """
    str_res = ""
    if inp_list:
        if isinstance(inp_list, list):
            str_res = delimiter.join(map(map_func, inp_list))
        else:
            raise AttributeError(f"Invalid inp_list provided to list_to_str: \n{inp_list}")
    return str_res


def log_indicator_line(raw_indicator: str, indicator: str, action: str, reason: str, log_stats: dict) -> dict:
    """Create and store a log line for the indicator.

    Args:
        raw_indicator (str): The raw indicator before modification.
        indicator (str): The indicator after modification.
        action (str): The action preformed, Added / Dropped / Modified.
        reason (str): The reason for the action.
        log_stats (dict): Stats of previous log entries.

    Returns:
        (dict) Updated log stats
    """
    log_line = f"\n{action} | {indicator} | {raw_indicator} | {reason}"
    append_log_edl_data(log_line)
    log_stats[action] = log_stats.get(action, 0) + 1

    return log_stats


def store_log_data(request_args: RequestArguments, created: datetime, log_stats: dict) -> None:
    """Finalizes and writes the full EDL log file by adding a summary header and appending logged indicator actions.

    Args:
        request_args (RequestArguments): The request args, they will be added to the header.
        created (datetime): The time the log was created. This will be added to the header.
        log_stats (dict): A statistics dict for the indicator modifications (e.g. {'Added': 5, 'Dropped': 3, 'Modified': 2}
    """
    log_file_wip = Path(EDL_FULL_LOG_PATH_WIP)
    if log_file_wip.exists():
        added_count = log_stats.get(IndicatorAction.ADDED.value, 0)
        dropped_count = log_stats.get(IndicatorAction.DROPPED.value, 0)
        modified_count = log_stats.get(IndicatorAction.MODIFIED.value, 0)

        total_count = added_count + dropped_count + modified_count

        header = (
            f"# Created new EDL at {created.isoformat()}\n\n"
            f"## Configuration Arguments: {request_args.to_context_json()}\n\n"
            f"## EDL stats: {total_count} indicators in total, {modified_count} modified, {dropped_count} dropped, "
            f"{added_count} added.\n"
            f"\nAction | Indicator | Raw Indicator | Reason"
        )

        with open(EDL_FULL_LOG_PATH, "w+") as new_full_log_file, log_file_wip.open("r") as log_file_data:
            # Finalize the current log: write the headers and the WIP log to full_log_path
            new_full_log_file.write(header)
            for log_line in log_file_data:
                new_full_log_file.write(log_line)

        with open(EDL_FULL_LOG_PATH_WIP, "w+") as log_file_data:
            # Empty WIP log file after finalization.
            log_file_data.seek(0)


@debug_function
def create_text_out_format(iocs: IO, request_args: RequestArguments) -> tuple[Union[IO, IO[str]], dict]:
    """
    Formats a stream of indicators (IOCs) into a standardized text output and logs processing actions.
    Args:
        iocs (IO): A file-like object with JSON lines, each representing an indicator with "value" and "indicator_type".
        request_args (RequestArguments): An object containing formatting preferences such as from the following categories:
            * IP / CIDR:
                1) if collapse_ips, collapse IPs/CIDRs
            * URL:
                1) if drop_invalids, drop invalids (length > 254 or has invalid chars)
                2) if port_stripping, strip ports
                3) if protocol_stripping, strip protocols
                4) if url_truncate, truncate urls
            * Other indicator types:
                1) if drop_invalids, drop invalids (has invalid chars)
                2) if port_stripping, strip ports
    Returns:
        tuple:
            - A temporary file-like object containing the formatted indicators, one per line.
            - A dictionary with statistics about dropped, added, and modified indicators.

    Behavior:
        - Skips indicators missing a value or non-ASCII (if configured).
        - For URLs/domains: applies cleaning (strip protocol/port, truncate) and filtering (length, invalid chars).
        - For IPs/CIDRs: can collapse into ranges or drop if too large.
        - Wildcard domains and TLDs are handled according to config.
        - All actions (add, drop, modify) are logged and counted in the result.
    """
    enforce_ascii = argToBoolean(demisto.params().get("enforce_ascii", False))
    ipv4_formatted_indicators = set()
    ipv6_formatted_indicators = set()
    iocs.seek(0)
    formatted_indicators = tempfile.TemporaryFile(mode="w+t")
    log_stats: dict = {}
    new_line = ""  # For the first time he will not add a new line
    for str_ioc in iocs:
        ioc = json.loads(str_ioc.rstrip())
        indicator_raw = ioc.get("value")
        if not indicator_raw:
            continue
        if enforce_ascii:
            try:
                indicator_raw.encode("ascii")
            except UnicodeEncodeError:
                continue
        ioc_type = ioc.get("indicator_type")

        indicator = indicator_raw

        if ioc_type not in [FeedIndicatorType.IP, FeedIndicatorType.IPv6, FeedIndicatorType.CIDR, FeedIndicatorType.IPv6CIDR]:
            indicator = url_handler(
                indicator_raw, request_args.url_protocol_stripping, request_args.url_port_stripping, request_args.url_truncate
            )

            if request_args.drop_invalids:
                if indicator != _PORT_REMOVAL.sub(_URL_WITHOUT_PORT, indicator) or indicator != _INVALID_TOKEN_REMOVAL.sub(
                    "*", indicator
                ):
                    # check if the indicator held invalid tokens or port
                    log_stats = log_indicator_line(
                        raw_indicator=indicator_raw,
                        indicator=indicator,
                        action=IndicatorAction.DROPPED.value,
                        reason="Invalid tokens or port.",
                        log_stats=log_stats,
                    )
                    continue

                if ioc_type == FeedIndicatorType.URL and len(indicator) >= PAN_OS_MAX_URL_LEN:
                    # URL indicator exceeds allowed length - ignore the indicator
                    log_stats = log_indicator_line(
                        raw_indicator=indicator_raw,
                        indicator=indicator,
                        action=IndicatorAction.DROPPED.value,
                        reason=f"URL exceeds max length {PAN_OS_MAX_URL_LEN}.",
                        log_stats=log_stats,
                    )

                    continue

            # for PAN-OS *.domain.com does not match domain.com
            # we should provide both
            # this could generate more than num entries according to PAGE_SIZE
            # Handle DomainGlob type indicators even when value doesn't start with "*."
            if indicator.startswith("*.") or ioc_type == FeedIndicatorType.DomainGlob:
                domain = str(indicator.lstrip("*."))
                # if we should ignore TLDs and the domain is a TLD
                if request_args.no_wildcard_tld and tldextract.extract(domain).suffix == domain:
                    log_stats = log_indicator_line(
                        raw_indicator=indicator_raw,
                        indicator=domain,
                        action=IndicatorAction.DROPPED.value,
                        reason="Domain is a TLD.",
                        log_stats=log_stats,
                    )
                    continue
                formatted_indicators.write(new_line + domain)
                new_line = "\n"

        if ioc_type in [FeedIndicatorType.CIDR, FeedIndicatorType.IPv6CIDR] and is_large_cidr(
            indicator, request_args.maximum_cidr_size
        ):
            log_stats = log_indicator_line(
                raw_indicator=indicator_raw,
                indicator=indicator,
                action=IndicatorAction.DROPPED.value,
                reason=f"CIDR exceeds max length {request_args.maximum_cidr_size}.",
                log_stats=log_stats,
            )
            continue

        if request_args.collapse_ips != DONT_COLLAPSE and ioc_type in (FeedIndicatorType.IP, FeedIndicatorType.CIDR):
            ipv4_formatted_indicators.add(indicator)

        elif request_args.collapse_ips != DONT_COLLAPSE and ioc_type == FeedIndicatorType.IPv6:
            ipv6_formatted_indicators.add(indicator)

        else:
            formatted_indicators.write(new_line + str(indicator))
            new_line = "\n"
            log_stats = log_indicator_line(
                raw_indicator=indicator_raw,
                indicator=indicator,
                action=IndicatorAction.ADDED.value,
                reason=f"Found new {ioc_type}.",
                log_stats=log_stats,
            )

    iocs.close()
    if len(ipv4_formatted_indicators) > 0:
        ipv4_formatted_indicators_collapsed = ips_to_ranges(ipv4_formatted_indicators, request_args.collapse_ips)
        for ip in ipv4_formatted_indicators_collapsed:
            formatted_indicators.write(new_line + str(ip))
            new_line = "\n"

        for ip in ipv4_formatted_indicators:
            if ip not in ipv4_formatted_indicators_collapsed:
                log_stats = log_indicator_line(
                    raw_indicator=ip,
                    indicator=ip,
                    action=IndicatorAction.MODIFIED.value,
                    reason=f"Collapsed IPv4 {request_args.collapse_ips}.",
                    log_stats=log_stats,
                )

            else:
                log_stats = log_indicator_line(
                    raw_indicator=ip,
                    indicator=ip,
                    action=IndicatorAction.ADDED.value,
                    reason="Found new IPv4.",
                    log_stats=log_stats,
                )

    if len(ipv6_formatted_indicators) > 0:
        ipv6_formatted_indicators_collapsed = ips_to_ranges(ipv6_formatted_indicators, request_args.collapse_ips)
        for ip in ipv6_formatted_indicators_collapsed:
            formatted_indicators.write(new_line + str(ip))
            new_line = "\n"

        for ip in ipv6_formatted_indicators:
            if ip not in ipv6_formatted_indicators_collapsed:
                log_stats = log_indicator_line(
                    raw_indicator=ip,
                    indicator=ip,
                    action=IndicatorAction.MODIFIED.value,
                    reason=f"Collapsed IPv6 {request_args.collapse_ips}.",
                    log_stats=log_stats,
                )

            else:
                log_stats = log_indicator_line(
                    raw_indicator=ip,
                    indicator=ip,
                    action=IndicatorAction.ADDED.value,
                    reason="Found new IPv6.",
                    log_stats=log_stats,
                )
    # Log on the logs files all the modification of the indicators
    return formatted_indicators, log_stats


def url_handler(indicator: str, url_protocol_stripping: bool, url_port_stripping: bool, url_truncate: bool) -> str:
    """
    * URL:
       1) if port_stripping, strip ports
       2) if protocol_stripping, strip protocols
       3) if url_truncate, truncate urls
    """

    # protocol stripping
    if url_protocol_stripping:
        indicator = _PROTOCOL_REMOVAL.sub("", indicator)

    if url_port_stripping:
        # remove port from indicator - from demisto.com:369/rest/of/path -> demisto.com/rest/of/path
        indicator = _PORT_REMOVAL.sub(_URL_WITHOUT_PORT, indicator)

    if url_truncate and len(indicator) >= PAN_OS_MAX_URL_LEN:
        indicator = indicator[0 : PAN_OS_MAX_URL_LEN - 1]

    return indicator


def get_outbound_mimetype(request_args: RequestArguments) -> str:
    """Returns the mimetype of the export_iocs"""
    if request_args.out_format == FORMAT_JSON:
        return MIMETYPE_JSON

    elif request_args.out_format == FORMAT_CSV and not request_args.csv_text:
        return MIMETYPE_CSV

    else:
        return MIMETYPE_TEXT


def append_log_edl_data(log_edl_data: str) -> None:
    """Store the generated log string in the log file.

    Args:
        log_edl_data (str): The generated log data string.
    """
    try:
        with open(EDL_FULL_LOG_PATH_WIP, "a") as last_full_log_file:
            last_full_log_file.write(log_edl_data)

    except Exception as e:
        demisto.debug(f"edl: Error in writing to log file: {e!s}")
        raise e


@debug_function
def get_edl_on_demand() -> tuple[str, int]:
    """
    Use the local file system to store the on-demand result, using a lock to
    limit access to the file from multiple threads.

    Returns:
        tuple[str, int]: A tuple of formatted indicators to display in EDL's response (str),
            and the number of original indicators received from the server before formatting (int).
    """
    global EDL_ON_DEMAND_CACHE_ORIGINAL_SIZE
    ctx = get_integration_context()

    if EDL_ON_DEMAND_KEY in ctx:
        ctx.pop(EDL_ON_DEMAND_KEY, None)
        request_args = RequestArguments.from_context_json(ctx)
        edl_data, EDL_ON_DEMAND_CACHE_ORIGINAL_SIZE, edl_data_stats = create_new_edl(request_args)
        created_time = datetime.now(timezone.utc)
        store_log_data(request_args, created_time, edl_data_stats)

        try:
            demisto.debug("edl: Writing EDL data to cache")

            with open(EDL_ON_DEMAND_CACHE_PATH, "w") as file:
                file.write(edl_data)

        except Exception as e:
            demisto.debug(f"edl: Error in writing to file: {e!s}")
            raise e

        if EXTENSIVE_LOGGING:
            demisto.debug("edl: Finished writing EDL data to cache")
        set_integration_context(ctx)

    else:
        demisto.debug("edl: Reading EDL data from cache")

        try:
            with open(EDL_ON_DEMAND_CACHE_PATH) as file:
                edl_data = file.read()

        except Exception as e:
            demisto.debug(f"edl: Error reading cache file: {e!s}")
            raise e
        demisto.debug("edl: Finished reading EDL data from cache")

    return edl_data, EDL_ON_DEMAND_CACHE_ORIGINAL_SIZE


def validate_basic_authentication(headers: dict, username: str, password: str) -> bool:
    """
    Checks whether the authentication is valid.
    :param headers: The headers of the http request
    :param username: The integration's username
    :param password: The integration's password
    :return: Boolean which indicates whether the authentication is valid or not
    """
    credentials: str = headers.get("Authorization", "")
    if not credentials or "Basic " not in credentials:
        return False
    encoded_credentials: str = credentials.split("Basic ")[1]
    credentials: str = b64decode(encoded_credentials).decode("utf-8")
    if ":" not in credentials:
        return False
    credentials_list = credentials.split(":")
    if len(credentials_list) != 2:
        return False
    user, pwd = credentials_list
    return user == username and pwd == password


def get_bool_arg_or_param(args: dict, params: dict, key: str):
    val = args.get(key)
    return val.lower() == "true" if isinstance(val, str) else params.get(key, False)


""" ROUTE FUNCTIONS """


def authenticate_app(params: dict, request_headers: Any) -> Optional[Response]:
    """Make sure the user is authenticated on API request.

    Args:
        params (dict): The demisto params, where the credentials are stored.
        request_headers: The request headers.

    Returns:
        (Response) '401 Login Required' on failure to authenticate.
        None on success.
    """
    credentials = params.get("credentials", {})
    username: str = credentials.get("identifier", "")
    password: str = credentials.get("password", "")
    if EXTENSIVE_LOGGING:
        demisto.debug("Attempting authentication for incoming request.")
    if username and password:
        headers: dict = cast(dict[Any, Any], request_headers)
        if not validate_basic_authentication(headers, username, password):
            err_msg: str = "Basic authentication failed. Make sure you are using the right credentials."
            demisto.debug(err_msg)
            return Response(
                err_msg,
                status=401,
                mimetype="text/plain",
                headers=[
                    ("WWW-Authenticate", 'Basic realm="Login Required"'),
                ],
            )

    return None


def get_edl_log_file() -> str:
    """Check if edl log file exists, if it does return its contents (str)."""
    edl_data_log = ""
    if os.path.exists(EDL_FULL_LOG_PATH):
        demisto.debug("found log file")
        if os.path.getsize(EDL_FULL_LOG_PATH) > MAX_DISPLAY_LOG_FILE_SIZE:
            if EXTENSIVE_LOGGING:
                demisto.debug("EDL log file too large to display")
            return LARGE_LOG_DISPLAY_MSG

        with open(EDL_FULL_LOG_PATH) as log_file:
            log_file.seek(0)
            edl_data_log = log_file.read()
            log_file.seek(0)

    return edl_data_log


def prepare_response_data(data: str, prepend_str: str, append_str: str) -> str:
    """Prepare data for app response.

    Args:
        data (str): The raw data.
        prepend_str (str): The string to prepend to the data.
        append_str (str): The string to append to the data.

    Returns:
        (str) The prepared data.
    """
    if append_str:
        append_str = append_str.replace("\\n", "\n")
        data = f"{data}{append_str}"
    if prepend_str:
        prepend_str = prepend_str.replace("\\n", "\n")
        data = f"{prepend_str}\n{data}"

    return data


def get_request_id() -> str:
    """Return the per-request correlation id for log lines.

    Reuses the ``X-Request-ID`` header that nginx forwards (the SAME id appears in
    the nginx access log and the WSGI ``wsgi request:``/``wsgi access:`` lines), so
    a single id can be grepped across NGINX -> WSGI -> EDL. Falls back to a fresh
    short uuid if the header is missing (e.g. a direct hit that bypassed nginx).
    """
    try:
        forwarded = request.headers.get("X-Request-ID")
    except RuntimeError:
        # No active Flask request context (e.g. called outside a route).
        demisto.debug("get_request_id called without an active Flask request context; generating a fresh id.")
        forwarded = None
    return forwarded if forwarded else uuid.uuid4().hex[:12]


@APP.route("/", methods=["GET"])
def route_edl() -> Response:
    """
    Main handler for values saved in the integration context
    """
    params = demisto.params()
    rid = get_request_id()
    cache_refresh_rate: str = params.get("cache_refresh_rate")
    start = datetime.now(timezone.utc)
    demisto.info(f"edl: rid={rid} route=/ start handling request")
    auth_resp = authenticate_app(params, request.headers)
    if auth_resp:
        demisto.info(f"edl: rid={rid} authentication failed; returning auth response")
        return auth_resp
    if EXTENSIVE_LOGGING:
        demisto.debug(f"edl: rid={rid} authentication successful")
    request_args = get_request_args(request.args, params)
    on_demand = params.get("on_demand")
    if EXTENSIVE_LOGGING:
        demisto.debug(f"edl: rid={rid} {'Using' if on_demand else 'Not using'} on-demand cache to serve EDL.")

    created = datetime.now(timezone.utc)
    if on_demand:
        edl_data, original_indicators_count = get_edl_on_demand()
    else:
        edl_data, original_indicators_count, edl_data_stats = create_new_edl(request_args)
        store_log_data(request_args, created, edl_data_stats)

    query_time = (datetime.now(timezone.utc) - created).total_seconds()
    etag = f'"{hashlib.sha1(edl_data.encode()).hexdigest()}"'  # nosec
    edl_size = 0

    if edl_data.strip():
        edl_size = edl_data.count("\n") + 1  # add 1 as last line doesn't have a \n

    if (len(edl_data) == 0 and request_args.add_comment_if_empty) or (edl_data == "]" and request_args.add_comment_if_empty):
        edl_data = "# Empty List"

    # if the case there are strings to add to the EDL, add them if the output type is text
    elif request_args.out_format == FORMAT_TEXT:
        edl_data = prepare_response_data(
            data=edl_data, append_str=params.get("append_string"), prepend_str=params.get("prepend_string")
        )
    if EXTENSIVE_LOGGING:
        demisto.debug(f"edl: rid={rid} Final EDL size: {len(edl_data)} characters, {edl_size} lines")

    mimetype = get_outbound_mimetype(request_args)
    max_age = ceil((datetime.now() - dateparser.parse(cache_refresh_rate)).total_seconds())  # type: ignore[operator]

    headers = [
        ("X-EDL-Created", created.isoformat()),
        ("X-EDL-Query-Time-Secs", f"{query_time:.3f}"),
        ("X-EDL-Size", str(edl_size)),
        ("X-EDL-Origin-Size", original_indicators_count),
        ("ETag", etag),
        # Echo the correlation id back so the client and nginx ($sent_http_x_request_id)
        # observe the same id that EDL/WSGI logged.
        ("X-Request-ID", rid),
    ]  # type: ignore[assignment]

    demisto.debug(
        f'edl: rid={rid} Returning response with the following headers:\n'
        f'{[f"{header[0]}: {header[1]}" for header in headers]}'
    )

    resp = Response(edl_data, status=200, mimetype=mimetype, headers=headers)
    resp.cache_control.max_age = max_age
    # number of seconds we are willing to serve stale content when there is an error
    resp.cache_control["stale-if-error"] = "600"

    total_time = (datetime.now(timezone.utc) - start).total_seconds()
    demisto.info(
        f"edl: rid={rid} route=/ done status=200 edl_size={edl_size} "
        f"chars={len(edl_data)} query_time_secs={query_time:.3f} total_secs={total_time:.3f}"
    )

    return resp


@APP.route("/log_download", methods=["GET"])
def log_download() -> Response:
    """Flask route to download the full EDL log file as a zip.
    The log file zipped is located at EDL_FULL_LOG_PATH.

    Return:
        Response: A Flask Response object that sends a ZIP file containing the full log.
    """
    params = demisto.params()
    rid = get_request_id()
    demisto.debug(f"edl: rid={rid} Starting EDL route/log_download handler")
    auth_resp = authenticate_app(params, request.headers)
    if EXTENSIVE_LOGGING:
        demisto.debug(f"edl: rid={rid} authentication successful")
    if auth_resp:
        return auth_resp

    demisto.debug(f"edl: rid={rid} Getting log file to show")

    created = datetime.now(timezone.utc)

    for previous_zip in glob.glob(f"{LOGS_ZIP_FILE_PREFIX}_*.zip"):
        os.remove(previous_zip)
    log_zip_filename = f'{LOGS_ZIP_FILE_PREFIX}_{created.strftime("%Y%m%d-%H%M%S")}.zip'
    zipf = zipfile.ZipFile(log_zip_filename, "w", zipfile.ZIP_DEFLATED)
    zipf.write(EDL_FULL_LOG_PATH)
    zipf.close()
    return send_file(log_zip_filename, mimetype="zip", download_name=log_zip_filename, as_attachment=True)


@APP.route("/log", methods=["GET"])
def route_edl_log() -> Response:
    """
    Flask route to serve the EDL indicators log via HTTP.

    Returns:
        Response: A Flask Response containing either:
            - The log contents as plain text (with optional prepend/append formatting), or
            - A ZIP file download containing the log, if the log is too large to display.
    """
    params = demisto.params()
    rid = get_request_id()

    cache_refresh_rate: str = params.get("cache_refresh_rate")
    demisto.debug(f"edl: rid={rid} Starting EDL route/log handler")
    auth_resp = authenticate_app(params, request.headers)
    if EXTENSIVE_LOGGING:
        demisto.debug(f"edl: rid={rid} authentication successful")
    if auth_resp:
        return auth_resp

    edl_data_log = get_edl_log_file() or "# Empty"
    request_args = get_request_args(request.args, params)
    created = datetime.now(timezone.utc)
    ctx = demisto.getIntegrationContext()

    # If edl_data_log is too large, first return a corresponding message as text.
    # Second, return the log as a file.
    # Alternate between the two via log_as_file context data key.
    if edl_data_log == LARGE_LOG_DISPLAY_MSG:
        # If we should return the log as a file this time
        if ctx.get("log_as_file", False):
            # Reset the log_as_file context key. Next time a message will be returned.
            ctx["log_as_file"] = False
            set_integration_context(ctx)
            # Remove previous zip versions of the log file if they exist.
            for previous_zip in glob.glob(f"{LOGS_ZIP_FILE_PREFIX}_*.zip"):
                os.remove(previous_zip)
            # zip the current log file and return it.
            log_zip_filename = f'{LOGS_ZIP_FILE_PREFIX}_{created.strftime("%Y%m%d-%H%M%S")}.zip'
            zipf = zipfile.ZipFile(log_zip_filename, "w", zipfile.ZIP_DEFLATED)
            zipf.write(EDL_FULL_LOG_PATH)
            zipf.close()
            return send_file(log_zip_filename, mimetype="zip", download_name=log_zip_filename, as_attachment=True)
        else:
            # Reset the log_as_file context key. Next time a file will be returned.
            ctx["log_as_file"] = True
            set_integration_context(ctx)

    if request_args.out_format == FORMAT_TEXT and edl_data_log not in ["# Empty", LARGE_LOG_DISPLAY_MSG]:
        ctx["log_as_file"] = False
        set_integration_context(ctx)
        edl_data_log = prepare_response_data(
            data=edl_data_log, append_str=params.get("append_string"), prepend_str=params.get("prepend_string")
        )

    etag = f'"{hashlib.sha3_256(edl_data_log.encode()).hexdigest()}"'
    headers = [("X-EDL-LOG-Request-Created", created.isoformat()), ("ETag", etag)]  # type: ignore[assignment]
    headers_str = f'{[f"{header[0]}: {header[1]}" for header in headers]}'
    demisto.debug(f"edl: Returning log response with the following headers:\n{headers_str}")
    max_age = ceil((datetime.now() - dateparser.parse(cache_refresh_rate)).total_seconds())  # type: ignore[operator]
    if edl_data_log == "# Empty":
        # If log file content was not created yet, refresh after 15 seconds.
        # If EDL indicator list refresh rate is less than 30s, refresh the log after half of the time.
        # This way, the corresponding log will be shown after at most 15 seconds.
        max_age = min(ceil(max_age / 2), 15)
    if edl_data_log == LARGE_LOG_DISPLAY_MSG:
        max_age = 0

    mimetype = get_outbound_mimetype(request_args)
    resp = Response(edl_data_log, status=200, mimetype=mimetype, headers=headers)
    resp.cache_control.max_age = max_age
    # number of seconds we are willing to serve stale content when there is an error
    resp.cache_control["stale-if-error"] = "600"

    return resp


def get_request_args(request_args: dict, params: dict) -> RequestArguments:
    """
    Processing a flask request arguments and generates a RequestArguments instance from it.
    Args:
        request_args: Flask request arguments
        params: Integration configuration parameters

    Returns:
        RequestArguments instance with processed arguments
    """
    if EXTENSIVE_LOGGING:
        demisto.debug("Parsing EDL request args...")
    limit = try_parse_integer(request_args.get("n", params.get("edl_size") or 10000), EDL_LIMIT_ERR_MSG)
    offset = try_parse_integer(request_args.get("s", 0), EDL_OFFSET_ERR_MSG)
    out_format = request_args.get("v", params.get("format", FORMAT_TEXT))
    query = request_args.get("q", params.get("indicators_query") or "")
    mwg_type = request_args.get("t", params.get("mwg_type", "string"))
    strip_port = request_args.get("sp", params.get("url_port_stripping") or False)
    strip_protocol = request_args.get("pr", params.get("url_protocol_stripping") or False)
    drop_invalids = request_args.get("di", params.get("drop_invalids") or False)
    category_default = request_args.get("cd", params.get("category_default", "bc_category"))
    category_attribute = request_args.get("ca", params.get("category_attribute", ""))
    collapse_ips = request_args.get("tr", params.get("collapse_ips", DONT_COLLAPSE))
    csv_text = request_args.get("tx", params.get("csv_text", False))
    add_comment_if_empty = request_args.get("ce", params.get("add_comment_if_empty", True))
    fields_to_present = request_args.get("fi", params.get("fields_filter", ""))
    url_truncate = request_args.get("ut", params.get("url_truncate", ""))
    maximum_cidr_size = try_parse_integer(
        request_args.get("mc", params.get("maximum_cidr_size", MAXIMUM_CIDR_SIZE_DEFAULT)), EDL_CIDR_SIZR_MSG
    )
    no_wildcard_tld = argToBoolean(request_args.get("nt", params.get("no_wildcard_tld")))

    # handle flags
    if drop_invalids == "":
        drop_invalids = True

    if strip_port == "":
        strip_port = True

    if strip_protocol == "":
        strip_port = True

    if collapse_ips not in [DONT_COLLAPSE, COLLAPSE_TO_CIDR, COLLAPSE_TO_RANGES]:
        collapse_ips = try_parse_integer(collapse_ips, EDL_COLLAPSE_ERR_MSG)

        if collapse_ips not in [0, 1, 2]:
            raise DemistoException(EDL_COLLAPSE_ERR_MSG)

        collapse_options = {0: DONT_COLLAPSE, 1: COLLAPSE_TO_RANGES, 2: COLLAPSE_TO_CIDR}
        collapse_ips = collapse_options[collapse_ips]
    if out_format not in [
        FORMAT_PROXYSG,
        FORMAT_TEXT,
        FORMAT_JSON,
        FORMAT_CSV,
        FORMAT_MWG,
        FORMAT_ARG_BLUECOAT,
        FORMAT_ARG_MWG,
        FORMAT_ARG_PROXYSG,
    ]:
        raise DemistoException(EDL_FORMAT_ERR_MSG)

    elif out_format in [FORMAT_ARG_PROXYSG, FORMAT_ARG_BLUECOAT]:
        out_format = FORMAT_PROXYSG

    elif out_format == FORMAT_ARG_MWG:
        out_format = FORMAT_MWG

    if out_format == FORMAT_MWG and mwg_type not in MWG_TYPE_OPTIONS:
        raise DemistoException(EDL_MWG_TYPE_ERR_MSG)

    if params.get("use_legacy_query"):
        # workaround for "msgpack: invalid code" error
        demisto.info(
            "Note: You are using a legacy query, it may have an impact on the performance of the integration."
            "This parameter is deprecated, make sure to adjust your queries accordingly."
        )
        fields_to_present = "use_legacy_query"

    if query and request_args.get("q"):
        demisto.debug(
            "Adjusting the number of exported indicators if above 100,000, due to using the q URL inline parameter."
            "For more information, review the documentation."
        )
        limit = min(limit, MAX_LIST_SIZE_WITH_URL_QUERY)

    demisto.debug(
        f"RequestArguments resolved:\n"
        f"limit={limit}, offset={offset}, format={out_format}, query='{query}', "
        f"mwg_type={mwg_type}, collapse_ips={collapse_ips}, drop_invalids={drop_invalids}, "
        f"strip_port={strip_port}, strip_protocol={strip_protocol}, "
        f"url_truncate={url_truncate}, maximum_cidr_size={maximum_cidr_size}, no_wildcard_tld={no_wildcard_tld}, "
        f"fields_to_present={fields_to_present}, category_default={category_default}, "
        f"category_attribute={category_attribute}, csv_text={csv_text}, comment_if_empty={add_comment_if_empty}"
    )

    return RequestArguments(
        query,
        out_format,
        limit,
        offset,
        strip_port,
        drop_invalids,
        collapse_ips,
        add_comment_if_empty,
        mwg_type,
        category_default,
        category_attribute,
        fields_to_present,
        csv_text,
        strip_protocol,
        url_truncate,
        maximum_cidr_size,
        no_wildcard_tld,
    )


""" COMMAND FUNCTIONS """


def test_module(_: dict, params: dict):
    """
    Validates:
        1. Valid port.
        2. Valid cache_refresh_rate
    """
    if not params.get("longRunningPort"):
        params["longRunningPort"] = "1111"
    get_params_port(params)
    on_demand = params.get("on_demand", None)
    if not on_demand:
        try_parse_integer(params.get("edl_size"), EDL_LIMIT_ERR_MSG)  # validate EDL Size was set
        cache_refresh_rate = params.get("cache_refresh_rate", "")
        if not cache_refresh_rate:
            raise ValueError(EDL_MISSING_REFRESH_ERR_MSG)
        # validate cache_refresh_rate value
        range_split = cache_refresh_rate.split(" ")
        if len(range_split) != 2:
            raise ValueError(EDL_MISSING_REFRESH_ERR_MSG)
        try_parse_integer(range_split[0], "Invalid time value for the Refresh Rate. Must be a valid integer.")
        if range_split[1] not in ["minute", "minutes", "hour", "hours", "day", "days", "month", "months", "year", "years"]:
            raise ValueError("Invalid time unit for the Refresh Rate. Must be minutes, hours, days, months, or years.")
        parse_date_range(cache_refresh_rate, to_timestamp=True)
    run_long_running(params, is_test=True)
    return "ok", {}, {}


@debug_function
def update_edl_command(args: dict, params: dict):
    """
    Updates the context to update the EDL values on demand the next time it runs.
    """
    on_demand = params.get("on_demand")
    if not on_demand:
        raise DemistoException('"Update EDL On Demand" is off. If you want to update the EDL manually please toggle it on.')
    limit = try_parse_integer(args.get("edl_size", params.get("edl_size")), EDL_LIMIT_ERR_MSG)
    query = args.get("query", "")
    collapse_ips = args.get("collapse_ips", DONT_COLLAPSE)
    url_port_stripping = get_bool_arg_or_param(args, params, "url_port_stripping")
    strip_protocol = get_bool_arg_or_param(args, params, "url_protocol_stripping")
    drop_invalids = get_bool_arg_or_param(args, params, "drop_invalids")
    add_comment_if_empty = get_bool_arg_or_param(args, params, "add_comment_if_empty")
    offset = try_parse_integer(args.get("offset", 0), EDL_OFFSET_ERR_MSG)
    mwg_type = args.get("mwg_type", "string")
    category_default = args.get("category_default", "bc_category")
    category_attribute = args.get("category_attribute", "")
    fields_to_present = args.get("fields_filter", "")
    out_format = args.get("format", FORMAT_TEXT)
    csv_text = get_bool_arg_or_param(args, params, "csv_text") == "True"
    url_truncate = get_bool_arg_or_param(args, params, "url_truncate")
    maximum_cidr_size = try_parse_integer(params.get("maximum_cidr_size", MAXIMUM_CIDR_SIZE_DEFAULT), EDL_CIDR_SIZR_MSG)
    no_wildcard_tld = argToBoolean(params.get("no_wildcard_tld", False))

    if params.get("use_legacy_query"):
        demisto.info(
            "Note: You are using a legacy query, it may have an impact on the performance of the integration."
            "This parameter is deprecated, make sure to adjust your queries accordingly."
        )
        # workaround for "msgpack: invalid code" error
        fields_to_present = "use_legacy_query"

    request_args = RequestArguments(
        query,
        out_format,
        limit,
        offset,
        url_port_stripping,
        drop_invalids,
        collapse_ips,
        add_comment_if_empty,
        mwg_type,
        category_default,
        category_attribute,
        fields_to_present,
        csv_text,
        strip_protocol,
        url_truncate,
        maximum_cidr_size,
        no_wildcard_tld,
    )

    ctx = request_args.to_context_json()
    ctx[EDL_ON_DEMAND_KEY] = True
    set_integration_context(ctx)
    hr = "EDL will be updated the next time you access it."

    if not query:
        warning = "\n**Warning**: Updating EDL, while not specifying a query, may load unwanted indicators."

        if param_query := params.get("query"):
            warning += f" Hint: use {param_query} to update indicators using the configured integration instance parameter."

        hr += warning
        demisto.info(warning)

    return hr, {}, {}


def initialize_edl_context(params: dict):
    global EDL_ON_DEMAND_CACHE_PATH
    limit = try_parse_integer(params.get("edl_size"), EDL_LIMIT_ERR_MSG)
    query = params.get("indicators_query", "")
    collapse_ips = params.get("collapse_ips", DONT_COLLAPSE)
    url_port_stripping = params.get("url_port_stripping", False)
    url_protocol_stripping = params.get("url_protocol_stripping", False)
    drop_invalids = params.get("drop_invalids", False)
    add_comment_if_empty = params.get("add_comment_if_empty", True)
    mwg_type = params.get("mwg_type", "string")
    category_default = params.get("category_default", "bc_category")
    category_attribute = params.get("category_attribute", "")
    fields_to_present = params.get("fields_filter", "")
    out_format = params.get("format", FORMAT_TEXT)
    csv_text = argToBoolean(params.get("csv_text", False))
    url_truncate = params.get("url_truncate", False)
    maximum_cidr_size = try_parse_integer(params.get("maximum_cidr_size", MAXIMUM_CIDR_SIZE_DEFAULT), EDL_CIDR_SIZR_MSG)
    no_wildcard_tld = argToBoolean(params.get("no_wildcard_tld", False))

    if params.get("use_legacy_query"):
        # workaround for "msgpack: invalid code" error
        demisto.info(
            "Note: You are using a legacy query, it may have an impact on the performance of the integration."
            "This parameter is getting deprecated, make sure to adjust your queries accordingly."
        )
        fields_to_present = "use_legacy_query"
    offset = 0
    request_args = RequestArguments(
        query,
        out_format,
        limit,
        offset,
        url_port_stripping,
        drop_invalids,
        collapse_ips,
        add_comment_if_empty,
        mwg_type,
        category_default,
        category_attribute,
        fields_to_present,
        csv_text,
        url_protocol_stripping,
        url_truncate,
        maximum_cidr_size,
        no_wildcard_tld,
    )

    EDL_ON_DEMAND_CACHE_PATH = demisto.uniqueFile()
    demisto.debug(f"The full log path: {EDL_FULL_LOG_PATH}")
    ctx = request_args.to_context_json()
    ctx[EDL_ON_DEMAND_KEY] = True
    set_integration_context(ctx)
    demisto.debug("Setting context data on demand to true.")


def check_platform_and_version(params: dict) -> bool:
    """
    Args:
        - params: The demisto params from the integration configuration.
    Returns:
        (bool): True if the platform is xsoar or xsoar hosted and no port specified, false otherwise
    """
    platform = demisto.demistoVersion().get("platform", "xsoar")
    return platform in ["xsoar", "xsoar_hosted"] and not is_demisto_version_ge("8.0.0") and not params.get("longRunningPort")


def main():  # pragma: no cover
    """
    Main
    """
    global PAGE_SIZE, EXTENSIVE_LOGGING
    params = demisto.params()
    try:
        PAGE_SIZE = max(1, int(params.get("page_size") or PAGE_SIZE))
    except ValueError:
        demisto.debug(f'Non integer "page_size" provided: {params.get("page_size")}. defaulting to {PAGE_SIZE}')
    credentials = params.get("credentials") if params.get("credentials") else {}
    username: str = credentials.get("identifier", "")
    password: str = credentials.get("password", "")

    EXTENSIVE_LOGGING = params.get("extensive_logging", False)

    if (username and not password) or (password and not username):
        err_msg: str = "If using credentials, both username and password should be provided."
        demisto.debug(err_msg)
        raise DemistoException(err_msg)

    command = demisto.command()
    demisto.debug(f"Command being called is {command}")
    commands = {"test-module": test_module, "edl-update": update_edl_command, "export-indicators-list-update": update_edl_command}

    try:
        if check_platform_and_version(params):
            raise DemistoException("Please specify a Listen Port, in the integration configuration")

        initialize_edl_context(params)
        if EXTENSIVE_LOGGING:
            demisto.debug("EDL context initialized.")
        if command == "long-running-execution":
            run_long_running(params)
        elif command in commands:
            readable_output, outputs, raw_response = commands[command](demisto.args(), params)
            return_outputs(readable_output, outputs, raw_response)
        else:
            raise NotImplementedError(f'Command "{command}" is not implemented.')
    except Exception as e:
        err_msg = f"Error in {INTEGRATION_NAME} Integration [{e}]"
        return_error(err_msg)


from NGINXApiModule import *  # noqa: E402

if __name__ in ["__main__", "__builtin__", "builtins"]:
    register_signal_handler_profiling_dump(profiling_dump_rows_limit=PROFILING_DUMP_ROWS_LIMIT)
    main()