Wiz

Agentless cloud security with bidirectional Issue mirroring, status sync, comment sync, and due-date sync between Wiz and Cortex XSOAR.

Utilities · Wiz

Details

IDWiz
ProviderGoogle
CategoryUtilities
From Version6.0.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Agentless, context-aware and full-stack security and compliance across AWS, Azure, GCP, OCI, Kubernetes, and other supported cloud platforms.
This integration was integrated and tested with Wiz

Configure Wiz in Cortex

Parameter Description Required  
Incident type   False ß
Service Account ID   True  
Password   True  
Authentication Endpoint Wiz Authentication Endpoint, e.g., https://auth.app.wiz.io/oauth/token False  
API Endpoint Wiz API Endpoint. Default: https://api.us1.app.wiz.io/graphql
To find your API endpoint URL:
1. Log in to Wiz, then open your user profile
2. Copy the API Endpoint URL to use here.
True  
Incidents Fetch Interval   False  
First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)   False  
Max Issues to Fetch   False  
Use system proxy settings   False  
Fetch incidents Issue Streaming type.
Either Fetch incidents (to constantly pull Issues) or Do not fetch (to push live Issues)
False  
Issue severity to fetch Select the severity levels of issues to fetch from Wiz. Multiple selections are allowed. Leave empty to fetch all severities. False  
Issue status to fetch Select the status of issues to fetch from Wiz. Multiple selections are allowed. False  
Issue type to fetch Select the type of issues to fetch from Wiz. Multiple selections are allowed. False  
Incident Mirror Direction Choose the mirroring direction for Wiz issues. Default is None (no mirroring). Cortex XSOAR only parameter. False  
Mirror API page size Page size for mirror API calls (1-500). All modified issues are fetched using this as the page size. False  
Tag for comment mirroring Add this tag to XSOAR war room entries to mirror them as Wiz issue notes. False  

Mirroring

The Wiz integration supports bidirectional mirroring between Wiz Issues and XSOAR incidents. Configure direction via the Incident Mirror Direction instance setting:

Direction Behavior
None Mirroring disabled. No dbotMirror* metadata is attached to fetched incidents.
Incoming Wiz → XSOAR only. Pulls remote status changes and notes into the XSOAR incident.
Outgoing XSOAR → Wiz only. Pushes XSOAR status changes, due-date changes, and tagged war room entries to the Wiz Issue.
Incoming And Outgoing Both directions active.

Mirrored fields

Field Direction Notes
Issue status Both XSOAR closed/active map to Wiz RESOLVED/OPEN. in_progress maps to IN_PROGRESS. Reopen restores OPEN.
Resolution reason Outgoing When closing in XSOAR, set resolutionReason (e.g. ISSUE_FIXED, WONT_FIX). When omitted, defaults to WONT_FIX.
Notes / comments Both Incoming: all Wiz issue notes are added as war room entries (formatted **Author** (timestamp): text). Service-account notes use **[SA] <name>**. Outgoing: only war room entries tagged with comment_tag (default comments) are pushed to Wiz.
Due date (dueAt) Outgoing Setting/clearing the XSOAR wizissueduedate field updates the Wiz Issue.

Loop prevention

Outgoing notes are prefixed with Mirrored from Cortex XSOAR and the Wiz integration filters them out on incoming sync, so mirrored notes are not echoed back into the war room.

First-sync behavior

On the first incoming sync after an incident is created in XSOAR, existing Wiz notes are not back-filled into the war room — only notes added after the first sync are mirrored. This avoids dumping the entire pre-existing note history into a fresh investigation.

Note truncation

Notes longer than 1400 characters are truncated and suffixed with ... [truncated] before being sent to the Wiz API. This applies to all mirrored notes and to the wiz-set-issue-note, wiz-resolve-issue, wiz-reject-issue, and wiz-defend-set-threat-comment commands.

Mirror engine commands

The following commands are invoked by the XSOAR mirroring engine and are not intended for manual use:

Command Purpose
get-remote-data Fetches updates for a single incident from Wiz.
get-modified-remote-data Returns the list of Wiz Issues modified since the last mirror cycle (paginated; page size = mirror_limit).
update-remote-system Pushes local XSOAR changes (status, notes, due date) back to Wiz.
get-mapping-fields Returns the schema of mappable fields. Used by the mapper UI.

Commands

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

wiz-get-issue


Get the details for a Wiz Issue ID.

Base Command

wiz-get-issue

Input

Argument Name Description Required
issue_id Issue id Required

Command Example

!wiz-get-issue issue_id="12345678-1234-1234-1234-cc0a24716e0b"

wiz-get-issues


Get the issues on cloud resources.

Base Command

wiz-get-issues

Input

| Argument Name | Description | Required |
|——————-|——————————————————————————————————————————————————————| — |
| issue_type | The type of Issue to get
Expected input: TOXIC_COMBINATION, THREAT_DETECTION, CLOUD_CONFIGURATION.
The chosen type will be fetched . | Optional |
| entity_type | The type of entity to get issues for. | Optional |
| resource_id | Get Issues of a specific resource_id.
Expected input: providerId | Optional |
| severity | Get Issues of a specific severuty.
Expected input: CRITICAL, HIGH, MEDIUM, LOW or INFORMATIONAL.
The chosen severity and above will be fetched | Optional |
entity_type and resource_id are mutually exclusive.

Context Output

Path Type Description
Wiz.Manager.Issues String All Issues

Command Example

!wiz-get-issues entity_type="VIRTUAL_MACHINE"
!wiz-get-issues issue_type="THREAT_DETECTION"
!wiz-get-issues resource_id="arn:aws:ec2:us-east-2:123456789098:instance/i-0g03j4h5gd123d456"
!wiz-get-issues resource_id="arn:aws:ec2:us-east-2:123456789098:instance/i-0g03j4h5gd123d456" severity=HIGH

wiz-get-resource


Get Details of a resource. You should pass exactly one of resource_id, resource_name.
When searching by name, results are limited to 500 records.

Base Command

wiz-get-resource

Input

Argument Name Description Required
resource_id Resource provider id optional
resource_name search by name or external ID optional

Context Output

Path Type Description
Wiz.Manager.Resource String Resource details

Command Example

!wiz-get-resource resource_id="arn:aws:ec2:us-east-2:123456789098:instance/i-0g03j4h5gd123d456"
!wiz-get-resource resource_name="i-0g03j4h5gd123d456"
!wiz-get-resource resource_name="test_vm"

wiz-get-resources


Get details of multiple resources based on various filters.

Base Command

wiz-get-resources

Input

Argument Name Description Required
search Filter by free text search on cloud resource name. Optional
entity_type Filter cloud resources by specific entity types. Possible values are: ACCESS_ROLE, ACCESS_ROLE_BINDING, ACCESS_ROLE_PERMISSION, API_GATEWAY, APPLICATION, AUTHENTICATION_CONFIGURATION, BACKUP_SERVICE, BUCKET, CDN, CERTIFICATE, CICD_SERVICE, CLOUD_LOG_CONFIGURATION, CLOUD_ORGANIZATION, COMPUTE_INSTANCE_GROUP, CONFIG_MAP, CONTAINER, CONTAINER_GROUP, CONTAINER_IMAGE, CONTAINER_REGISTRY, CONTAINER_SERVICE, DAEMON_SET, DATABASE, DATA_WORKLOAD, DB_SERVER, DEPLOYMENT, DNS_RECORD, DNS_ZONE, DOMAIN, EMAIL_SERVICE, ENCRYPTION_KEY, ENDPOINT, FILE_SYSTEM_SERVICE, FIREWALL, GATEWAY, GOVERNANCE_POLICY, GOVERNANCE_POLICY_GROUP, HOSTED_APPLICATION, IAM_BINDING, IP_RANGE, KUBERNETES_CLUSTER, KUBERNETES_CRON_JOB, KUBERNETES_INGRESS, KUBERNETES_INGRESS_CONTROLLER, KUBERNETES_JOB, KUBERNETES_NETWORK_POLICY, KUBERNETES_NODE, KUBERNETES_PERSISTENT_VOLUME, KUBERNETES_PERSISTENT_VOLUME_CLAIM, KUBERNETES_POD_SECURITY_POLICY, KUBERNETES_SERVICE, KUBERNETES_STORAGE_CLASS, KUBERNETES_VOLUME, LOAD_BALANCER, MANAGED_CERTIFICATE, MANAGEMENT_SERVICE, NETWORK_ADDRESS, NETWORK_INTERFACE, NETWORK_ROUTING_RULE, NETWORK_SECURITY_RULE, PEERING, POD, PORT_RANGE, PRIVATE_ENDPOINT, PROXY, PROXY_RULE, RAW_ACCESS_POLICY, REGISTERED_DOMAIN, REPLICA_SET, RESOURCE_GROUP, SEARCH_INDEX, SECRET, SECRET_CONTAINER, SERVERLESS, SERVERLESS_PACKAGE, SERVICE_ACCOUNT, STORAGE_ACCOUNT, SUBNET, SUBSCRIPTION, SWITCH, USER_ACCOUNT, VIRTUAL_DESKTOP, VIRTUAL_MACHINE, VIRTUAL_MACHINE_IMAGE, VIRTUAL_NETWORK, VOLUME, WEB_SERVICE, DATA_WORKFLOW. Optional
subscription_external_ids Filter cloud resources according to these external subscription IDs (AWS Account, Azure Subscription, GCP Project, and OCI Compartment). You can provide multiple IDs separated by commas. Optional
provider_unique_ids Filter cloud resources according to these cloud service provider unique IDs. You can provide multiple IDs separated by commas. Optional
project_ids Filter by Wiz project IDs (comma-separated). Optional
native_types Filter by cloud-native resource types (comma-separated, e.g. aws_ec2_instance). Optional
updated_at_before Filter resources updated before this date (ISO 8601, e.g. 2024-01-01T00:00:00Z). Optional
updated_at_after Filter resources updated after this date (ISO 8601, e.g. 2024-01-01T00:00:00Z). Optional

At least one parameter must be provided.

Context Output

This command returns the raw response data from the Wiz API. The response includes resource details in JSON format.

Command Example

!wiz-get-resources search="web-server"
!wiz-get-resources entity_type="VIRTUAL_MACHINE"
!wiz-get-resources subscription_external_ids="123456789,987654321"
!wiz-get-resources provider_unique_ids="i-0g03j4h5gd123d456"
!wiz-get-resources entity_type="BUCKET" search="backup"

wiz-issue-in-progress


Set a Wiz Issue to in progress.

Base Command

wiz-issue-in-progress

Input

Argument Name Description Required
issue_id Issue id Required

Context Output

Path Type Description
Wiz.Manager.Issue String Issue details

Command Example

!wiz-issue-in-progress issue_id="12345678-1234-1234-1234-cc0a24716e0b"

wiz-reopen-issue


Re-open an Issue.

Base Command

wiz-reopen-issue

Input

Argument Name Description Required
issue_id Issue id Required
reopen_note Note for re-opening Issue Optional

Context Output

Path Type Description
Wiz.Manager.Issue String Issue details

Command Example

!wiz-reopen-issue issue_id="12345678-1234-1234-1234-cc0a24716e0b" reopen_note="still an issue"

wiz-reject-issue


Reject a Wiz Issue. Not supported for THREAT_DETECTION issues.

Base Command

wiz-reject-issue

Input

Argument Name Description Required
issue_id Issue id Required
reject_reason Rejection reason. Possible values are: FALSE_POSITIVE, EXCEPTION, WONT_FIX. Required
reject_note Note for the rejection. Notes longer than 1400 characters are truncated and suffixed with ... [truncated]. Required

Context Output

Path Type Description
Wiz.Manager.Issue String Issue details

Command Example

!wiz-reject-issue issue_id="12345678-1234-1234-1234-cc0a24716e0b" reject_reason="WONT_FIX" reject_note="this is by design"

wiz-resolve-issue


Resolve a Wiz Issue.

Base Command

wiz-resolve-issue

Input

Argument Name Description Required
issue_id Issue id Required
resolution_reason Issue resolution reason. Possible values are: OBJECT_DELETED, ISSUE_FIXED, FALSE_POSITIVE, EXCEPTION, WONT_FIX. Required
resolution_note Note to explain why the Issue has been resolved. Notes longer than 1400 characters are truncated and suffixed with ... [truncated]. Required

Context Output

Path Type Description
Wiz.Manager.Issue String Issue details

Command Example

!wiz-resolve-issue issue_id="12345678-1234-1234-1234-cc0a24716e0b" resolution_note="won't fix this issue as this is low priority" resolution_reason="WONT_FIX"

wiz-set-issue-note


Set (append) a note to an Issue.

Base Command

wiz-set-issue-note

Input

Argument Name Description Required
issue_id Issue id Required
note Note for the Issue. Will be appended to existing notes. Notes longer than 1400 characters are truncated and suffixed with ... [truncated]. Required

Command Example

!wiz-set-issue-note issue_id="12345678-1234-1234-1234-cc0a24716e0b" note="Checking with owner"

wiz-clear-issue-note


Clears a note from an Issue.

Base Command

wiz-clear-issue-note

Input

Argument Name Description Required
issue_id Issue id Required

Command Example

!wiz-clear-issue-note issue_id="12345678-1234-1234-1234-cc0a24716e0b"

wiz-get-issue-evidence


Get the evidence from an Issue.

Base Command

wiz-get-issue-evidence

Input

Argument Name Description Required
issue_id Issue id Required

Command Example

!wiz-get-issue-evidence issue_id="12345678-1234-1234-1234-cc0a24716e0b"

wiz-rescan-machine-disk


Deprecated

wiz-set-issue-due-date


Set a due date for an Issue.

Base Command

wiz-set-issue-due-date

Input

Argument Name Description Required
issue_id Issue id Required
due_at Due At Date. Format must be YYYY-MM-DD (e.g. 2026-12-31). Required

Command Example

!wiz-set-issue-due-date issue_id="12345678-1234-1234-1234-cc0a24716e0b" due_at="2022-01-20"

wiz-clear-issue-due-date


Clear a due date for an Issue.

Base Command

wiz-clear-issue-due-date

Input

Argument Name Description Required
issue_id Issue id Required

Command Example

!wiz-clear-issue-due-date issue_id="12345678-1234-1234-1234-cc0a24716e0b"

wiz-get-project-team


Get the Project Owners and Security Champions details.

Base Command

wiz-get-project-team

Input

Argument Name Description Required
project_name Project Name Required

Command Example

!wiz-get-project-team project_name="project1"

wiz-copy-to-forensics-account


Copy VM’s Volumes to a Forensics Account

Base Command

wiz-copy-to-forensics-account

Input

Argument Name Description Required
resource_id Resource Id Required

Command Example

!wiz-copy-to-forensics-account resource_id="12345678-1234-1234-1234-cc0a24716e0b"
!wiz-copy-to-forensics-account resource_id="arn:aws:ec2:us-east-1:123455563321:instance/i-05r662bfb9708a4e8"

Configuration parameters

  • incidentType — Incident type
  • credentials — Service Account ID (required)
  • auth_endpoint — Authentication Endpoint
  • api_endpoint — API Endpoint (required)
  • incidentFetchInterval — Incidents Fetch Interval
  • first_fetch — First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)
  • max_fetch — Max Issues to Fetch
  • proxy — Use system proxy settings
  • isFetch — Fetch incidents
  • severity — Issue severity to fetch
  • status — Issue status to fetch
  • issue_type — Issue type to fetch
  • mirror_direction — Incident Mirror Direction
  • mirror_limit — Mirror API page size
  • comment_tag — Tag for comment mirroring

Commands (20)

  • get-mapping-fields

    Returns the list of fields for an incident type. Called by the mirroring engine, not for manual use.

  • get-modified-remote-data

    Get the list of incidents modified since the last update. Called by the mirroring engine, not for manual use.

  • get-remote-data

    Get remote data for a single incident. Called by the mirroring engine, not for manual use.

  • update-remote-system

    Push local changes to Wiz. Called by the mirroring engine, not for manual use.

  • wiz-clear-issue-due-date

    Clear a due date on a Wiz Issue.

  • wiz-clear-issue-note

    Clear a note from a Wiz Issue.

  • wiz-copy-to-forensics-account

    Copy VM's Volumes to a Forensics Account.

  • wiz-get-issue

    Get the details of a Wiz Issue Id.

  • wiz-get-issue-evidence

    Get the Wiz Issue evidence.

  • wiz-get-issues

    Get the Issues on cloud resources.

  • wiz-get-project-team

    Get the Project Owners and Security Champions details.

  • wiz-get-resource

    Get details of a resource.

  • wiz-get-resources

    Get details of resources.

  • wiz-issue-in-progress

    Set a Wiz Issue to in progress.

  • wiz-reject-issue

    Reject a Wiz Issue.

  • wiz-reopen-issue

    Reopen a Wiz Issue.

  • wiz-rescan-machine-disk Deprecated

    DEPRECATED.

  • wiz-resolve-issue

    Resolve a Wiz Threat Detection Issue. For non-Threat-Detection issues (Toxic Combination, Cloud Configuration, Attack Surface) use wiz-reject-issue — those types are auto-resolved by Wiz when the underlying problem is fixed and cannot be manually resolved.

  • wiz-set-issue-due-date

    Set a due date on a Wiz Issue.

  • wiz-set-issue-note

    Set a note on a Wiz Issue.

import copy
import json
import random

import pytest
from typing import Any
from unittest.mock import patch, MagicMock
import demistomock as demisto

from CommonServerPython import DemistoException
from Packs.Wiz.Integrations.Wiz.Wiz import (
    ValidationResponse,
    WizStatus,
    WizSeverity,
    WizIssueType,
    WizMirrorDirection,
    WizMirrorParam,
    WizMirrorField,
    XSOAR_MIRROR_MARKER,
    WIZ_MIRRORED_FIELDS,
    DEFAULT_RESOLUTION_REASON,
    get_fetch_issues_variables,
    PULL_ISSUES_DEFAULT_VARIABLES,
    DEFAULT_FETCH_ISSUE_STATUS,
    apply_all_issue_filters,
    apply_status_filter,
    apply_severity_filter,
    apply_wiz_filter,
    validate_status,
    validate_severity,
    validate_issue_type,
    validate_wiz_enum_parameter,
    build_incidents,
    apply_issue_type_filter,
    validate_all_issues_parameters,
)

integration_params = {
    "url": "http://test.io",
    "credentials": {"identifier": "test", "password": "pass"},
    "fetch_time": "7 days",
    "max_fetch": 5,
}

integration_params_with_auth_url = copy.deepcopy(integration_params)
integration_params_with_auth_url.update({"auth_endpoint": "https://auth.wiz.io/oauth/token"})

TEST_TOKEN = "123456789"
SIMILAR_COMMANDS = [
    "wiz-issue-in-progress",
    "wiz-reopen-issue",
    "wiz-reject-issue",
    "wiz-get-issues",
    "wiz-get-resource",
    "wiz-get-issue",
    "wiz-set-issue-note",
    "wiz-clear-issue-note",
    "wiz-get-issue-evidence",
    "wiz-set-issue-due-date",
    "wiz-clear-issue-due-date",
    "wiz-get-project-team",
    "wiz-resolve-issue",
    "wiz-copy-to-forensics-account",
]


@pytest.fixture(autouse=True)
def set_mocks(mocker):
    mocker.patch.object(demisto, "params", return_value=integration_params_with_auth_url)


test_get_issues_response = {
    "data": {
        "issues": {
            "nodes": [
                {
                    "id": "12345678-1234-1234-1234-d25e16359c19",
                    "control": {
                        "id": "12345678-4321-4321-4321-3792e8a03318",
                        "name": "test delete",
                    },
                    "type": "THREAT_DETECTION",
                    "createdAt": "2022-01-02T15:46:34Z",
                    "updatedAt": "2022-01-04T10:40:57Z",
                    "status": "OPEN",
                    "severity": "CRITICAL",
                    "entity": {"bla": "lot more blah was here", "name": "virtualMachine", "type": "virtualMachine"},
                }
            ],
            "pageInfo": {"hasNextPage": False, "endCursor": ""},
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_get_issues_response)
def test_get_filtered_issues(checkAPIerrors):
    from Wiz import get_filtered_issues

    result_response = [
        {
            "id": "12345678-1234-1234-1234-d25e16359c19",
            "control": {
                "id": "12345678-4321-4321-4321-3792e8a03318",
                "name": "test delete",
            },
            "createdAt": "2022-01-02T15:46:34Z",
            "updatedAt": "2022-01-04T10:40:57Z",
            "status": "OPEN",
            "type": "THREAT_DETECTION",
            "severity": "CRITICAL",
            "entity": {"bla": "lot more blah was here", "name": "virtualMachine", "type": "virtualMachine"},
        }
    ]

    res = get_filtered_issues("virtualMachine", "", "CRITICAL", "", 500)
    assert res == result_response


@patch("Wiz.checkAPIerrors", return_value=test_get_issues_response)
def test_get_issue(checkAPIerrors):
    from Wiz import get_issue

    result_response = [
        {
            "id": "12345678-1234-1234-1234-d25e16359c19",
            "control": {
                "id": "12345678-4321-4321-4321-3792e8a03318",
                "name": "test delete",
            },
            "createdAt": "2022-01-02T15:46:34Z",
            "updatedAt": "2022-01-04T10:40:57Z",
            "status": "OPEN",
            "type": "THREAT_DETECTION",
            "severity": "CRITICAL",
            "entity": {"bla": "lot more blah was here", "name": "virtualMachine", "type": "virtualMachine"},
        }
    ]

    res = get_issue("d6f7a886-e2f5-44c0-980c-c7c17bbfb7dd")
    assert res == result_response


test_get_resource_response = {
    "data": {
        "cloudResources": {
            "nodes": [{"id": "16da9341-6c72-46ba-948c-f0c057643e60", "name": "test_name_vm", "type": "VIRTUAL_MACHINE"}]
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_get_resource_response)
def test_get_resource_by_id(checkAPIerrors):
    from Wiz import get_resource

    res = get_resource(resource_id="i_am_an_id", resource_name="")
    assert res == test_get_resource_response


test_get_resources_response = {
    "data": {
        "cloudResources": {
            "nodes": [
                {
                    "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                    "name": "view",
                    "type": "ACCESS_ROLE",
                    "subscriptionId": "12345678-2222-3333-1111-ff5fa2ff7f78",
                    "subscriptionExternalId": "123456789",
                    "graphEntity": {
                        "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                        "providerUniqueId": None,
                        "name": "view",
                        "type": "ACCESS_ROLE",
                        "projects": [
                            {"id": "12345678-2222-3333-1111-ff5fa2ff1111"},
                            {"id": "12345678-2222-3333-1111-ff5fa2ff2222"},
                            {"id": "12345678-2222-3333-1111-ff5fa2ff3333"},
                        ],
                        "firstSeen": "2025-02-12T21:03:10.981466Z",
                        "lastSeen": "2025-03-24T00:01:31Z",
                    },
                }
            ]
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_get_resources_response)
def test_get_resources(checkAPIerrors):
    from Wiz import get_resources

    res = get_resources(
        search="search",
        entity_type="ACCESS_ROLE",
        subscription_external_ids="123456789",
        provider_unique_ids="12345678-2222-3333-1111-ff5fa2ff7f78",
        project_ids=None,
        native_types=None,
        updated_at_before=None,
        updated_at_after=None,
    )
    assert res == test_get_resources_response


@patch("Wiz.checkAPIerrors", return_value=test_get_resources_response)
def test_get_resources_with_new_filters(checkAPIerrors):
    from Wiz import get_resources

    res = get_resources(
        search=None,
        entity_type=None,
        subscription_external_ids=None,
        provider_unique_ids=None,
        project_ids="proj-1, proj-2",
        native_types="aws_ec2_instance, aws_s3_bucket",
        updated_at_before=None,
        updated_at_after="2024-01-01T00:00:00Z",
    )
    assert res == test_get_resources_response
    call_args = checkAPIerrors.call_args
    variables = call_args[0][1]
    assert variables["filterBy"]["projectId"] == ["proj-1", "proj-2"]
    assert variables["filterBy"]["nativeType"] == ["aws_ec2_instance", "aws_s3_bucket"]
    assert variables["filterBy"]["updatedAt"] == {"after": "2024-01-01T00:00:00Z"}


def test_get_resources_wrong_input(capfd):
    from Wiz import get_resources

    with capfd.disabled():
        res = get_resources(search=None, entity_type=None, subscription_external_ids=None, provider_unique_ids=None)
        assert "You should pass (at least) one of the following parameters" in res
        assert "search" in res
        assert "entity_type" in res
        assert "provider_unique_ids" in res
        assert "subscription_external_ids" in res


test_get_resource_response_search = {
    "data": {
        "cloudResources": {
            "nodes": [{"id": "16da9341-6c72-46ba-948c-f0c057643e60", "name": "test_name_vm", "type": "VIRTUAL_MACHINE"}]
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_get_resource_response_search)
def test_get_resource_by_search(checkAPIerrors):
    from Wiz import get_resource

    res = get_resource(resource_id="", resource_name="test_name_vm")
    assert res == test_get_resource_response_search


def test_get_resource_fail(capfd):
    with capfd.disabled():
        from Wiz import get_resource

        res = get_resource(resource_id="", resource_name="")
        assert res == "You should pass exactly one of resource_name or resource_id"

        res = get_resource(resource_id="12345678-2222-3333-1111-ff5fa2ff7f78", resource_name="test_name_vm")
        assert res == "You should pass exactly one of resource_name or resource_id"


test_reject_issue_response = {
    "data": {
        "updateIssue": {
            "issue": {
                "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                "note": "blah_note",
                "status": "REJECTED",
                "dueAt": "2022-01-14T20:24:20Z",
                "resolutionReason": "WONT_FIX",
            }
        }
    }
}

# Define the return values
resolve_issues_return_values = [test_get_issues_response, test_reject_issue_response]


# Define a function that will serve as the side effect
def side_effect(*args, **kwargs):
    return resolve_issues_return_values.pop(0)


@patch("Wiz.checkAPIerrors", side_effect=side_effect)
def test_resolve_issue(checkAPIerrors, capfd):
    from Wiz import resolve_issue

    with capfd.disabled():
        res = resolve_issue(None, 1, 2)
        assert "You should pass" in res

    res = resolve_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "WONT_FIX", "blah_note")
    assert res == test_reject_issue_response


_resolve_issue_non_threat_response = {
    "data": {
        "issues": {
            "nodes": [{"id": "12345678-2222-3333-1111-ff5fa2ff7f78", "type": "TOXIC_COMBINATION"}],
            "pageInfo": {"hasNextPage": False, "endCursor": ""},
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=_resolve_issue_non_threat_response)
def test_resolve_issue_non_threat_returns_friendly_error(checkAPIerrors, capfd):
    from Wiz import resolve_issue

    with capfd.disabled():
        res = resolve_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "ISSUE_FIXED", "blah_note")

    assert "Only a Threat Detection Issue can be resolved" in res
    assert "TOXIC_COMBINATION" in res
    assert "wiz-reject-issue" in res
    # The integration must short-circuit before the update mutation, so checkAPIerrors
    # is called exactly once (the _get_issue lookup) and never for reject_or_resolve_issue.
    assert checkAPIerrors.call_count == 1


_resolve_issue_unknown_id_response = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": ""}}}}


@patch("Wiz.checkAPIerrors", return_value=_resolve_issue_unknown_id_response)
def test_resolve_issue_unknown_id_returns_not_found(checkAPIerrors):
    from Wiz import resolve_issue

    res = resolve_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "ISSUE_FIXED", "blah_note")

    assert res == "Issue not found: 12345678-2222-3333-1111-ff5fa2ff7f78"
    # Same short-circuit guarantee as above — no update mutation should be sent.
    assert checkAPIerrors.call_count == 1


@patch("Wiz.checkAPIerrors", return_value=test_reject_issue_response)
def test_reject_issue(checkAPIerrors, capfd):
    from Wiz import reject_issue

    with capfd.disabled():
        res = reject_issue(None, 1, 2)
        assert "You should pass" in res

    res = reject_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "WONT_FIX", "blah_note")
    assert res == test_reject_issue_response


test_issue_id_not_valid = "Error details: Resource not found"


@patch("Wiz.checkAPIerrors", return_value=test_issue_id_not_valid)
def test_reject_issue_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import reject_issue

        res = reject_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "WONT_FIX", "blah_note")
        assert res == "Error details: Resource not found"


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_reject_issue_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import reject_issue

        try:
            reject_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "WONT_FIX", "blah_note")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_get_issue_evidence_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import get_issue_evidence

        try:
            get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_clear_issue_due_date_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import clear_issue_due_date

        try:
            clear_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78")
        except DemistoException:
            assert True


test_reopen_issue_response = {
    "data": {
        "updateIssue": {
            "issue": {
                "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                "note": "blah_note",
                "status": "OPEN",
                "dueAt": "2022-01-14T20:24:20Z",
                "resolutionReason": "",
            }
        }
    }
}


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_reopen_issue_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import reopen_issue

        try:
            reopen_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "blah_note")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_issue_in_progress_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import issue_in_progress

        try:
            issue_in_progress("12345678-2222-3333-1111-ff5fa2ff7f78")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_set_issue_note_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import set_issue_comment

        try:
            set_issue_comment("12345678-2222-3333-1111-ff5fa2ff7f78", "blah_note")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_clear_issue_note_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import clear_issue_note

        try:
            clear_issue_note("12345678-2222-3333-1111-ff5fa2ff7f78")
        except DemistoException:
            assert True


@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_set_issue_date_exception(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import set_issue_due_date

        try:
            set_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78", "2022-01-20")
        except DemistoException:
            assert True


@patch("Wiz.return_error", side_effect=Exception("no command"))
def test_main_without_params(return_error, capfd):
    from Wiz import main

    with pytest.raises(Exception) as e:
        main()
    assert str(e.value) == "no command"
    captured = capfd.readouterr()
    assert "Unrecognized command" in captured.out


def test_no_command(mocker):
    from Wiz import main

    mocker.patch.object(demisto, "command", return_value="test-module")
    mocker.patch("Wiz.get_token", return_value=TEST_TOKEN)
    mocker.patch("Wiz.checkAPIerrors", return_value=test_issue_id_not_valid)
    main()


INVALID_RESPONSE_ERROR = "blabla error blabla"

VALID_RESPONSE_JSON = {
    "data": {
        "issues": {
            "nodes": [
                {
                    "id": "b00bb5ce-4493-4158-af64-e21c6776331b",
                    "name": "test-1",
                    "createdAt": "2022-07-06T11:21:28.372924Z",
                    "type": "CORTEX_XSOAR",
                    "evidenceQuery": "test_query",
                    "status": "SUCCESS",
                    "notes": [{"id": "test_note_result"}],
                    "project": None,
                    "isAccessibleToAllProjects": True,
                    "params": {
                        "url": "https://bla.bla",
                        "authentication": {"username": "A", "password": "__secret_content__"},
                        "clientCertificate": None,
                        "body": "{}",
                    },
                    "usedByRules": [],
                },
                {
                    "id": "123456-test-id-2",
                    "name": "test-2",
                    "createdAt": "2022-07-06T11:21:28.372924Z",
                    "type": "CORTEX_XSOAR",
                    "status": "SUCCESS",
                    "project": None,
                    "isAccessibleToAllProjects": True,
                    "params": {
                        "url": "https://bla.bla",
                        "authentication": {"username": "A", "password": "__secret_content__"},
                        "clientCertificate": None,
                        "body": "{}",
                    },
                    "usedByRules": [],
                },
            ],
            "pageInfo": {"hasNextPage": False, "endCursor": None},
            "totalCount": 2,
        },
        "graphSearch": {"nodes": [{"entities": [{"id": "test_id"}]}]},
        "issue": {"note": None, "control": {"query": "blabla"}, "status": "CRITICAL", "resolutionReason": "blabla reason"},
        "projects": {
            "nodes": [
                {
                    "id": "12345678-cfa4-5268-a6a9-987654321",
                    "name": "test-test-test",
                    "isFolder": True,
                    "archived": False,
                    "businessUnit": "R&D",
                    "description": "test description",
                    "projectOwners": [{"id": "project@test.io", "name": "Test Owner", "email": "test.owner@wiz.io"}],
                    "securityChampions": [{"id": "champion@test.io", "name": "Test Champion", "email": "test.champion@wiz.io"}],
                }
            ]
        },
        "cloudResources": {
            "nodes": [{"id": "test_id", "name": "test_name", "type": "test_type", "properties": {"test": "test"}}]
        },
        "copyResourceForensicsToExternalAccount": {"systemActivityGroupId": "test_id"},
    }
}

DEMISTO_ARGS = {
    "issue_type": "Publicly exposed VM instance with effective global admin permissions",
    "resource_id": "test-id",
    "severity": "CRITICAL",
    "reject_note": "reject_note_test",
    "issue_id": 123456,
    "reject_reason": "reject_reason_test",
    "reopen_note": "reopen_note_test",
    "note": "test-note",
    "resource_name": "resource_name",
}


@patch("Wiz.checkAPIerrors", return_value=test_reopen_issue_response)
def test_reopen_issue_direct(checkAPIerrors):
    from Wiz import reopen_issue

    res = reopen_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "blah_note")
    assert res == test_reopen_issue_response


@patch("Wiz.checkAPIerrors", return_value=test_issue_id_not_valid)
def test_set_issue_reopen_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import reopen_issue

        res = reopen_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "blah_note")
        assert res == "Error details: Resource not found"


test_issue_in_progress_response = {
    "data": {
        "updateIssue": {
            "issue": {
                "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                "note": "blah_note",
                "status": "IN_PROGRESS",
                "dueAt": "2022-01-14T20:24:20Z",
                "resolutionReason": "",
            }
        }
    }
}


@pytest.mark.parametrize("command_name", SIMILAR_COMMANDS)
def test_main_command(mocker, capfd, command_name):
    from Wiz import main

    with capfd.disabled():
        mocker.patch.object(demisto, "command", return_value=command_name)
        mocker.patch.object(demisto, "args", return_value=DEMISTO_ARGS)
        mocker.patch("Wiz.checkAPIerrors", return_value=VALID_RESPONSE_JSON)
        mocker.patch("CommonServerPython.tableToMarkdown", return_value=[])
        main()


def test_has_next_page(mocker, capfd):
    from Wiz import fetch_issues

    with capfd.disabled():
        valid_json_paging = copy.deepcopy(VALID_RESPONSE_JSON)
        valid_json_paging["data"]["issues"]["pageInfo"]["hasNextPage"] = True
        valid_json_paging["data"]["issues"]["pageInfo"]["endCursor"] = "test"
        mocker.patch("Wiz.checkAPIerrors", side_effect=[valid_json_paging, VALID_RESPONSE_JSON])
        mocker.patch("CommonServerPython.tableToMarkdown", return_value=[])
        mocker.patch.object(demisto, "getLastRun", return_value={"time": "2025-01-01T00:00:00Z"})
        fetch_issues(450)


def test_get_project_team(mocker, capfd):
    from Wiz import get_project_team

    with capfd.disabled():
        mocker.patch("Wiz.checkAPIerrors", return_value=VALID_RESPONSE_JSON)
        project = get_project_team("test_project")
        assert project[0]["projectOwners"][0]["name"] == "Test Owner"
        assert project[0]["securityChampions"][0]["name"] == "Test Champion"

        mocker.patch("Wiz.checkAPIerrors", side_effect=DemistoException("demisto exception"))
        project = get_project_team("test_project")
        assert not project


@pytest.mark.parametrize("severity", ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"])
def test_get_filtered_issues_good_severity(mocker, capfd, severity):
    from Wiz import get_filtered_issues

    with capfd.disabled():
        valid_json_paging = copy.deepcopy(VALID_RESPONSE_JSON)
        valid_json_paging["data"]["issues"]["pageInfo"]["hasNextPage"] = True
        valid_json_paging["data"]["issues"]["pageInfo"]["endCursor"] = "test"
        mocker.patch("Wiz.checkAPIerrors", side_effect=[valid_json_paging, VALID_RESPONSE_JSON])
        get_filtered_issues(entity_type="virtualMachine", resource_id="", severity=severity, issue_type="", limit=500)


@pytest.mark.parametrize("severity", ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"])
def test_get_filtered_issues_good_severity_resource(mocker, capfd, severity):
    from Wiz import get_filtered_issues

    with capfd.disabled():
        valid_json_paging = copy.deepcopy(VALID_RESPONSE_JSON)
        valid_json_paging["data"]["issues"]["pageInfo"]["hasNextPage"] = True
        valid_json_paging["data"]["issues"]["pageInfo"]["endCursor"] = "test"
        mocker.patch("Wiz.checkAPIerrors", side_effect=[valid_json_paging, VALID_RESPONSE_JSON])
        get_filtered_issues(entity_type="", resource_id="test_resource", severity=severity, issue_type="", limit=500)


def test_get_filtered_issues_bad_arguments(mocker, capfd):
    from Wiz import get_filtered_issues

    with capfd.disabled():
        mocker.patch("Wiz.checkAPIerrors", return_value=VALID_RESPONSE_JSON)
        issue = get_filtered_issues(entity_type="virtualMachine", resource_id="test", severity="BAD", issue_type="", limit=500)
        assert issue == "You cannot pass entity_type and resource_id together\n"
        issue = get_filtered_issues(entity_type="", resource_id="", severity="", issue_type="", limit=500)
        assert (
            issue == "You should pass (at least) one of the following parameters:\n\tentity_type\n\tresource_id"
            "\n\tseverity\n\tissue_type\n\tcreated_after\n\tcreated_before\n"
        )
        issue = get_filtered_issues(entity_type="virtualMachine", resource_id="", severity="BAD", issue_type="", limit=500)
        assert (
            issue == "You should only use these severity types: CRITICAL, HIGH, MEDIUM, LOW or "
            "INFORMATIONAL in upper or lower case."
        )


def test_get_issue_bad_arguments(mocker, capfd):
    from Wiz import get_issue

    with capfd.disabled():
        mocker.patch("Wiz.checkAPIerrors", return_value=VALID_RESPONSE_JSON)
        issue = get_issue(issue_id="virtualMachine")
        assert issue == "Wrong format: The Issue ID should be in UUID format."
        issue = get_issue(issue_id="")
        assert issue == "You should pass an Issue ID."


@patch("Wiz.checkAPIerrors", return_value=test_issue_in_progress_response)
def test_issue_in_progress(checkAPIerrors):
    from Wiz import issue_in_progress

    res = issue_in_progress("12345678-2222-3333-1111-ff5fa2ff7f78")
    assert res == test_issue_in_progress_response


@patch("Wiz.checkAPIerrors", return_value=test_issue_id_not_valid)
def test_set_issue_in_progress_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import issue_in_progress

        res = issue_in_progress("12345678-2222-3333-1111-ff5fa2ff7f78")
        assert res == "Error details: Resource not found"


test_set_issue_note_response = {
    "data": {
        "issue": {
            "id": "12345678-1234-1234-1234-d25e16359c19",
            "control": {
                "id": "12345678-4321-4321-4321-3792e8a03318",
                "name": "test delete",
            },
            "createdAt": "2022-01-02T15:46:34Z",
            "updatedAt": "2022-01-04T10:40:57Z",
            "status": "OPEN",
            "note": "blah note",
            "severity": "CRITICAL",
            "entity": {"bla": "lot more blah was here", "name": "virtualMachine", "type": "virtualMachine"},
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_set_issue_note_response)
def test_set_issue_note(checkAPIerrors):
    from Wiz import set_issue_comment

    res = set_issue_comment("12345678-2222-3333-1111-ff5fa2ff7f78", "blah_note")
    assert res == test_set_issue_note_response


@patch("Wiz.checkAPIerrors", return_value=test_set_issue_note_response)
def test_set_issue_note_truncates_long_note(checkAPIerrors):
    from Wiz import set_issue_comment, MAX_NOTE_LENGTH

    long_note = "a" * (MAX_NOTE_LENGTH + 1)
    set_issue_comment("12345678-2222-3333-1111-ff5fa2ff7f78", long_note)

    call_args = checkAPIerrors.call_args
    sent_text = call_args[0][1]["input"]["text"]
    assert len(sent_text) <= MAX_NOTE_LENGTH
    assert sent_text.endswith("... [truncated]")


def test_truncate_note():
    from Wiz import truncate_note, MAX_NOTE_LENGTH

    # Short notes are unchanged
    assert truncate_note("short note") == "short note"
    assert truncate_note("") == ""
    assert truncate_note(None) is None

    # Exactly at limit is unchanged
    exact = "a" * MAX_NOTE_LENGTH
    assert truncate_note(exact) == exact

    # Over limit gets truncated
    long_text = "a" * (MAX_NOTE_LENGTH + 1)
    result = truncate_note(long_text)
    assert len(result) <= MAX_NOTE_LENGTH
    assert result.endswith("... [truncated]")
    assert len(result) == MAX_NOTE_LENGTH


test_set_issue_note_fail_response = {
    "errors": [
        {
            "message": "Resource not found",
            "extensions": {"code": "NOT_FOUND", "exception": {"message": "Resource not found", "path": ["issue"]}},
        }
    ],
    "data": None,
}


@patch("Wiz.checkAPIerrors", return_value=test_issue_id_not_valid)
def test_set_issue_note_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import set_issue_comment

        res = set_issue_comment("12345678-2222-3333-1111-ff5fa2ff7f78", "blah")
        assert res == "Error details: Resource not found"


test_clear_issue_note_response = {
    "data": {
        "updateIssue": {
            "issue": {
                "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                "note": "",
                "status": "REJECTED",
                "dueAt": "2022-01-14T20:24:20Z",
                "resolutionReason": "WONT_FIX",
            }
        }
    }
}

test_clear_issue_note_fail_response = (
    "Error details: Only the user who created the note, can delete it.\n" "Check server.log file for additional information"
)


@patch("Wiz._get_issue", return_value=VALID_RESPONSE_JSON)
@patch("Wiz.checkAPIerrors", return_value=test_clear_issue_note_fail_response)
def test_clear_issue_note_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import clear_issue_note

        res = clear_issue_note("12345678-2222-3333-1111-ff5fa2ff7f78")
        assert (
            res == "Error details: Only the user who created the note, can delete it.\n"
            "Check server.log file for additional information"
        )


@patch("Wiz._get_issue", return_value=VALID_RESPONSE_JSON)
@patch("Wiz.checkAPIerrors", side_effect=DemistoException("no command"))
def test_get_issue_evidence_failure(checkAPIerrors, _get_issue, capfd):
    with capfd.disabled():
        from Wiz import get_issue_evidence

        with pytest.raises(Exception) as e:
            get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
        assert "Failed getting Issue evidence on ID 12345678-1234-1234-1234-d25e16359c19" in str(e)


test_get_issue_evidence_tdr_response = {
    "data": {"issues": {"nodes": [{"type": "THREAT_DETECTION", "evidenceQuery": {}, "threatDetectionDetails": {"data": "data"}}]}}
}


@patch("Wiz._get_issue", return_value=test_get_issue_evidence_tdr_response)
@patch("Wiz.checkAPIerrors", return_value=test_get_issue_evidence_tdr_response)
def test_get_issue_evidence_tdr(checkAPIerrors, _get_issue):
    from Wiz import get_issue_evidence

    res = get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
    assert res == test_get_issue_evidence_tdr_response["data"]["issues"]["nodes"][0]["threatDetectionDetails"]


test_get_issue_evidence_control_response = {
    "data": {
        "issues": {"nodes": [{"type": "TOXIC_COMBINATION", "evidenceQuery": {"data": "data"}, "threatDetectionDetails": None}]}
    }
}

test_get_evidence_control_response = {"data": {"graphSearch": {"nodes": [{"entities": [{"id": "12345678-222"}]}]}}}


@patch("Wiz._get_issue", return_value=test_get_issue_evidence_control_response)
@patch("Wiz.checkAPIerrors", return_value=test_get_evidence_control_response)
def test_get_issue_evidence_control(checkAPIerrors, _get_issue):
    from Wiz import get_issue_evidence

    res = get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
    assert res == test_get_evidence_control_response["data"]["graphSearch"]["nodes"][0]["entities"]


test_get_issue_empty_issue_response = {"data": {"issues": {"nodes": []}}}


@patch("Wiz._get_issue", return_value=test_get_issue_empty_issue_response)
def test_get_issue_evidence_no_issue(_get_issue):
    from Wiz import get_issue_evidence

    res = get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
    assert res == "Issue not found: 12345678-1234-1234-1234-d25e16359c19"


test_get_issue_response_query_exists = {
    "data": {
        "issues": {"nodes": [{"type": "TOXIC_COMBINATION", "evidenceQuery": {"data": "data"}, "threatDetectionDetails": {}}]}
    }
}

test_get_evidence_empty = {"data": {"graphSearch": {"nodes": []}}}

test_get_evidence_empty_none = {"data": {"graphSearch": {"nodes": None}}}


@patch("Wiz._get_issue", return_value=test_get_issue_response_query_exists)
@patch("Wiz.checkAPIerrors", return_value=test_get_evidence_empty)
def test_get_issue_evidence_no_evidence(checkAPIerrors, _get_issue):
    from Wiz import get_issue_evidence

    res = get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
    assert res == "No Evidence Found"


@patch("Wiz._get_issue", return_value=test_get_issue_response_query_exists)
@patch("Wiz.checkAPIerrors", return_value=test_get_evidence_empty_none)
def test_get_issue_evidence_no_resource(checkAPIerrors, _get_issue):
    from Wiz import get_issue_evidence

    res = get_issue_evidence("12345678-1234-1234-1234-d25e16359c19")
    assert res == "Resource Not Found"


test_set_issue_due_data_response = {
    "data": {
        "updateIssue": {
            "issue": {
                "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
                "note": "",
                "status": "OPEN",
                "dueAt": "2022-01-20T00:00:00.000Z",
                "resolutionReason": None,
            }
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_set_issue_due_data_response)
def test_set_issue_due_date(checkAPIerrors):
    from Wiz import set_issue_due_date

    res = set_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78", "2022-01-20")
    assert res == test_set_issue_due_data_response


@patch("Wiz.checkAPIerrors", return_value="The date format is the incorrect. It should be YYYY-MM-DD")
def test_set_issue_due_date_failed(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import set_issue_due_date

        res = set_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78", "01-20-2022")
        assert res == "The date format is the incorrect. It should be YYYY-MM-DD"


@patch("Wiz.checkAPIerrors", return_value="errors blabla")
def test_set_issue_due_date_error(checkAPIerrors, capfd):
    with capfd.disabled():
        from Wiz import set_issue_due_date

        res = set_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78", "2022-01-20")
        assert "errors blabla" in res


test_clear_issue_due_data_response = {
    "data": {
        "issue": {
            "id": "12345678-2222-3333-1111-ff5fa2ff7f78",
            "note": "",
            "status": "OPEN",
            "dueAt": None,
            "resolutionReason": None,
        }
    }
}


@patch("Wiz.checkAPIerrors", return_value=test_clear_issue_due_data_response)
def test_clear_issue_due_date(checkAPIerrors):
    from Wiz import clear_issue_due_date

    res = clear_issue_due_date("12345678-2222-3333-1111-ff5fa2ff7f78")
    assert res == test_clear_issue_due_data_response


test_clear_issue_due_data_failed_response = {
    "errors": [
        {
            "message": "Resource not found",
            "extensions": {"code": "NOT_FOUND", "exception": {"message": "Resource not found", "path": ["updateIssue"]}},
        }
    ],
    "data": None,
}

test_bad_token_response = {"error": "access_denied", "error_description": "Unauthorized"}


def mocked_requests_get(json, status):
    class MockResponse:
        def __init__(self, json, status_code):
            self.json_data = json
            self.status_code = status_code

        def json(self):
            return self.json_data

    return MockResponse(json, status)


def test_bad_get_token(capfd):
    with capfd.disabled(), patch("requests.post") as mocked_request, pytest.raises(Exception):
        mocked_request().return_value = test_bad_token_response
        from Wiz import get_token

        res = get_token()
        assert res == test_bad_token_response


def test_generate_auth_urls_fed():
    from Wiz import generate_auth_urls_fed

    prefix = "auth"
    expected_auth_url = "auth.wiz.us/oauth/token"
    expected_http_auth_url = "https://auth.wiz.us/oauth/token"
    auth_url, http_auth_url = generate_auth_urls_fed(prefix)
    assert auth_url == expected_auth_url
    assert http_auth_url == expected_http_auth_url

    prefix = ""
    expected_auth_url = ".wiz.us/oauth/token"
    expected_http_auth_url = "https://.wiz.us/oauth/token"
    auth_url, http_auth_url = generate_auth_urls_fed(prefix)
    assert auth_url == expected_auth_url
    assert http_auth_url == expected_http_auth_url

    prefix = "auth!@#"
    expected_auth_url = "auth!@#.wiz.us/oauth/token"
    expected_http_auth_url = "https://auth!@#.wiz.us/oauth/token"
    auth_url, http_auth_url = generate_auth_urls_fed(prefix)
    assert auth_url == expected_auth_url
    assert http_auth_url == expected_http_auth_url


def test_token_url():
    from Wiz import COGNITO_PREFIX, AUTH0_PREFIX, generate_auth_urls

    cognito_allowlist = [
        "auth.app.wiz.io/oauth/token",
        "https://auth.app.wiz.io/oauth/token",
        "auth.gov.wiz.io/oauth/token",
        "https://auth.gov.wiz.io/oauth/token",
        "auth.test.wiz.io/oauth/token",
        "https://auth.test.wiz.io/oauth/token",
    ]
    auth0_allowlist = [
        "auth.wiz.io/oauth/token",
        "https://auth.wiz.io/oauth/token",
        "auth0.gov.wiz.io/oauth/token",
        "https://auth0.gov.wiz.io/oauth/token",
        "auth0.test.wiz.io/oauth/token",
        "https://auth0.test.wiz.io/oauth/token",
    ]

    cognito_list = []
    for cognito_prefix in COGNITO_PREFIX:
        cognito_list.extend(generate_auth_urls(cognito_prefix))
    assert cognito_list.sort() == cognito_allowlist.sort()

    auth0_list = []
    for auth0_prefix in AUTH0_PREFIX:
        auth0_list.extend(generate_auth_urls(auth0_prefix))
    assert auth0_list.sort() == auth0_allowlist.sort()


def test_good_token(capfd, mocker):
    with capfd.disabled():
        good_token = str(random.randint(1, 1000))
        mocker.patch("requests.post", return_value=mocked_requests_get({"access_token": good_token}, 200))

        from Wiz import get_token, set_authentication_endpoint, generate_auth_urls, AUTH_DEFAULT

        set_authentication_endpoint("https://auth.wiz.io/oauth/token")
        res = get_token()
        assert res == good_token

        set_authentication_endpoint(generate_auth_urls(AUTH_DEFAULT)[1])
        res = get_token()
        assert res == good_token

        set_authentication_endpoint("auth.wiz.io/oauth/token")
        res = get_token()
        assert res == good_token

        set_authentication_endpoint("bad")
        from Wiz import get_token

        with pytest.raises(Exception) as e:
            get_token()
        assert str(e.value) == "Not a valid authentication endpoint"


def test_token_no_access(capfd, mocker):
    with capfd.disabled():
        mocker.patch("requests.post", return_value=mocked_requests_get({}, 200))
        from Wiz import get_token, set_authentication_endpoint

        with pytest.raises(Exception) as e:
            set_authentication_endpoint("auth.app.wiz.io/oauth/token")
            get_token()
        assert "Could not retrieve token from Wiz" in str(e.value)


def test_check_api_access(capfd, mocker):
    with capfd.disabled():
        good_token = str(random.randint(1, 1000))
        mocker.patch("requests.post", return_value=mocked_requests_get({"access_token": good_token}, 200))
        from Wiz import checkAPIerrors

        checkAPIerrors(query="test", variables="test")

        mocker.patch("Wiz.get_token", return_value=TEST_TOKEN)
        mocker.patch("requests.post", side_effect=Exception("bad request"))
        with pytest.raises(Exception) as e:
            checkAPIerrors(query="test", variables="test")
        assert str(e.value) == "bad request"


def test_check_api_access_bad_gw(capfd, mocker):
    with capfd.disabled():
        from Wiz import checkAPIerrors

        mocker.patch("requests.post", side_effect=Exception("502: Bad Gateway"))

        with pytest.raises(Exception) as e:
            checkAPIerrors(query="test", variables="test")
        assert "502: Bad Gateway" in str(e.value)


test_issue_severity_crit_response = {
    "id": "12345678-2222-3333-1111-ff5fa2ff7f71",
    "note": "",
    "severity": "CRITICAL",
    "status": "OPEN",
    "dueAt": None,
    "resolutionReason": None,
}


def test_translate_severity_crit(capfd):
    with capfd.disabled():
        from Wiz import translate_severity

        res = translate_severity(test_issue_severity_crit_response)
        assert res == 4


test_issue_severity_high_response = {
    "id": "12345678-2222-3333-1111-ff5fa2ff7f71",
    "note": "",
    "severity": "HIGH",
    "status": "OPEN",
    "dueAt": None,
    "resolutionReason": None,
}


def test_translate_severity_high(capfd):
    with capfd.disabled():
        from Wiz import translate_severity

        res = translate_severity(test_issue_severity_high_response)
        assert res == 3


test_issue_severity_med_response = {
    "id": "12345678-2222-3333-1111-ff5fa2ff7f71",
    "note": "",
    "severity": "MEDIUM",
    "status": "OPEN",
    "dueAt": None,
    "resolutionReason": None,
}


def test_translate_severity_med(capfd):
    with capfd.disabled():
        from Wiz import translate_severity

        res = translate_severity(test_issue_severity_med_response)
        assert res == 2


test_issue_severity_low_response = {
    "id": "12345678-2222-3333-1111-ff5fa2ff7f71",
    "note": "",
    "severity": "LOW",
    "status": "OPEN",
    "dueAt": None,
    "resolutionReason": None,
}


def test_translate_severity_low(capfd):
    with capfd.disabled():
        from Wiz import translate_severity

        res = translate_severity(test_issue_severity_low_response)
        assert res == 1


test_issue_severity_info_response = {
    "id": "12345678-2222-3333-1111-ff5fa2ff7f71",
    "note": "",
    "severity": "INFORMATIONAL",
    "status": "OPEN",
    "dueAt": None,
    "resolutionReason": None,
}


def test_translate_severity_info(capfd):
    with capfd.disabled():
        from Wiz import translate_severity

        res = translate_severity(test_issue_severity_info_response)
        assert res == 0.5


test_build_incidents_response = None


def test_build_incidents_none(capfd):
    with capfd.disabled():
        from Wiz import build_incidents

        res = build_incidents(test_build_incidents_response)
        assert res == {}


test_copy_to_forensics_account_response = {
    "data": {"copyResourceForensicsToExternalAccount": {"systemActivityGroupId": "16dee032-9a56-4331-aca5-0df2a9d47745"}}
}


@patch("Wiz.checkAPIerrors", return_value=test_copy_to_forensics_account_response)
def test_copy_to_forensics_account(checkAPIerrors):
    from Wiz import copy_to_forensics_account

    res = copy_to_forensics_account("12345678-1234-1234-1234-d25e16359c19")
    assert res == test_copy_to_forensics_account_response


test_get_resource_id_using_arn_response = {
    "data": {"cloudResources": {"nodes": [{"id": "12345678-1234-1234-1234-d25e16359c19"}]}}
}


def test_copy_to_forensics_account_provider_id(mocker):
    from Wiz import copy_to_forensics_account

    mocker.patch(
        "Wiz.checkAPIerrors",
        return_value=VALID_RESPONSE_JSON,
        side_effect=[test_get_resource_id_using_arn_response, test_copy_to_forensics_account_response],
    )
    res = copy_to_forensics_account("arn:aws:ec2:us-east-1:452225563321:instance/i-05r662bfb9708a4e8")
    assert res == test_copy_to_forensics_account_response


test_get_resource_id_using_arn_response_error = {
    "data": None,
    "errors": {
        "message": "Resource not found",
    },
}


def test_copy_to_forensics_account_invalid_id(mocker, capfd):
    from Wiz import copy_to_forensics_account

    with capfd.disabled():
        mocker.patch(
            "Wiz.checkAPIerrors",
            side_effect=[test_get_resource_id_using_arn_response, test_get_resource_id_using_arn_response_error],
        )
        issue = copy_to_forensics_account(resource_id="invalid_uuid")
        assert issue == (
            "Resource with ID 12345678-1234-1234-1234-d25e16359c19 was not copied to Forensics Account. "
            "error: {'message': 'Resource not found'}"
        )


import pytest
from unittest.mock import patch


# ===== BUILD INCIDENTS TESTS =====


@pytest.mark.parametrize(
    "issue,expected_has_incident",
    [
        (None, False),
        ({}, True),
        ({"id": "test-id"}, True),
        ({"id": "test-id", "sourceRule": {"name": "Test Rule"}}, True),
        ({"id": "test-id", "sourceRule": None}, True),
        ({"id": "test-id", "createdAt": "2025-01-01T00:00:00Z"}, True),
    ],
)
@patch("Packs.Wiz.Integrations.Wiz.Wiz.translate_severity")
@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_build_incidents(mock_demisto, mock_translate_severity, issue, expected_has_incident):
    """Test build_incidents with various issue inputs"""
    mock_translate_severity.return_value = "Medium"

    result = build_incidents(issue)

    if expected_has_incident:
        assert "name" in result
        assert "occurred" in result
        assert "rawJSON" in result
        assert "severity" in result
        if issue and issue.get("id"):
            assert issue["id"] in result["name"]
        assert result["rawJSON"] == json.dumps(issue)
        assert result["severity"] == "Medium"
    else:
        assert result == {}


@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_build_incidents_exception(mock_demisto):
    """Test build_incidents with exception handling"""
    issue = {"id": "test-id"}

    with patch("Packs.Wiz.Integrations.Wiz.Wiz.translate_severity", side_effect=Exception("Test error")):
        with pytest.raises(Exception) as exc_info:
            build_incidents(issue)

        # Verify the exception message contains the expected content
        assert "build_incidents: Error processing issue test-id: Test error" in str(exc_info.value)


# ===== VALIDATION TESTS =====


@pytest.mark.parametrize(
    "parameter_value,enum_values,expected_valid,expected_values",
    [
        ("CRITICAL", ["CRITICAL", "HIGH", "MEDIUM"], True, ["CRITICAL"]),
        ("CRITICAL,HIGH", ["CRITICAL", "HIGH", "MEDIUM"], True, ["CRITICAL", "HIGH"]),
        (["CRITICAL", "HIGH"], ["CRITICAL", "HIGH", "MEDIUM"], True, ["CRITICAL", "HIGH"]),
        ("INVALID", ["CRITICAL", "HIGH", "MEDIUM"], False, None),
        ("CRITICAL,INVALID", ["CRITICAL", "HIGH", "MEDIUM"], False, None),
        ("", ["CRITICAL", "HIGH", "MEDIUM"], True, None),
        (None, ["CRITICAL", "HIGH", "MEDIUM"], True, None),
    ],
)
@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_validate_wiz_enum_parameter(mock_demisto, parameter_value, enum_values, expected_valid, expected_values, capfd):
    """Test validate_wiz_enum_parameter with various inputs"""
    mock_enum_class = MagicMock()
    mock_enum_class.values.return_value = enum_values

    with capfd.disabled():
        result = validate_wiz_enum_parameter(parameter_value, mock_enum_class, "test parameter")

    assert result.is_valid == expected_valid
    if expected_valid and parameter_value:
        assert result.value == expected_values


@pytest.mark.parametrize(
    "issue_type,expected_valid",
    [
        ("TOXIC_COMBINATION", True),
        ("CLOUD_CONFIGURATION", True),
        ("THREAT_DETECTION", True),
        ("INVALID_TYPE", False),
        ("TOXIC_COMBINATION,THREAT_DETECTION", True),
        (["TOXIC_COMBINATION", "CLOUD_CONFIGURATION"], True),
        (None, True),
        ("", True),
    ],
)
def test_validate_issue_type(issue_type, expected_valid, capfd):
    """Test validate_issue_type with various inputs"""
    with capfd.disabled():
        result = validate_issue_type(issue_type)
    assert result.is_valid == expected_valid


@pytest.mark.parametrize(
    "severity,expected_valid",
    [
        ("CRITICAL", True),
        ("HIGH", True),
        ("MEDIUM", True),
        ("LOW", True),
        ("INFORMATIONAL", True),
        ("INVALID_SEVERITY", False),
        ("CRITICAL,HIGH", True),
        (["CRITICAL", "HIGH"], True),
        (None, True),
        ("", True),
    ],
)
def test_validate_severity(severity, expected_valid, capfd):
    """Test validate_severity with various inputs"""
    with capfd.disabled():
        result = validate_severity(severity)
    assert result.is_valid == expected_valid


@pytest.mark.parametrize(
    "status,expected_valid",
    [
        ("OPEN", True),
        ("IN_PROGRESS", True),
        ("REJECTED", True),
        ("RESOLVED", True),
        ("INVALID_STATUS", False),
        ("OPEN,IN_PROGRESS", True),
        (["OPEN", "RESOLVED"], True),
        (None, True),
        ("", True),
    ],
)
def test_validate_status(status, expected_valid, capfd):
    """Test validate_status with various inputs"""
    with capfd.disabled():
        result = validate_status(status)
    assert result.is_valid == expected_valid


@pytest.mark.parametrize(
    "parameters,expected_success",
    [
        ({"issue_type": "TOXIC_COMBINATION", "status": "OPEN", "severity": "CRITICAL"}, True),
        ({"issue_type": "INVALID_TYPE", "status": "OPEN", "severity": "CRITICAL"}, False),
        ({"issue_type": "TOXIC_COMBINATION", "status": "INVALID_STATUS", "severity": "CRITICAL"}, False),
        ({"issue_type": "TOXIC_COMBINATION", "status": "OPEN", "severity": "INVALID_SEVERITY"}, False),
        ({}, True),
        ({"issue_type": None, "status": None, "severity": None}, True),
    ],
)
def test_validate_all_issues_parameters(parameters, expected_success, capfd):
    """Test validate_all_issues_parameters with various parameter combinations"""
    with capfd.disabled():
        success, error_message, validated_values = validate_all_issues_parameters(parameters)

    assert success == expected_success
    if expected_success:
        assert error_message is None
        assert validated_values is not None
    else:
        assert error_message is not None


# ===== FILTER APPLICATION TESTS =====


@pytest.mark.parametrize(
    "filter_value,api_field,equals_wrapper,expected_result",
    [
        ("CRITICAL", "severity", False, {"filterBy": {"severity": ["CRITICAL"]}}),
        (["CRITICAL", "HIGH"], "severity", False, {"filterBy": {"severity": ["CRITICAL", "HIGH"]}}),
        ("OPEN", "status", True, {"filterBy": {"status": {"equals": ["OPEN"]}}}),
        (None, "severity", False, {}),
        ("", "severity", False, {}),
    ],
)
def test_apply_wiz_filter(filter_value, api_field, equals_wrapper, expected_result):
    """Test apply_wiz_filter with various inputs"""
    variables = {}
    result = apply_wiz_filter(variables, filter_value, api_field, equals_wrapper)

    if filter_value:
        assert result == expected_result
    else:
        assert result == variables


def test_apply_wiz_filter_nested_path():
    """Test apply_wiz_filter with nested path"""
    variables = {}
    result = apply_wiz_filter(variables, "AWS", "cloudPlatform", True, "relatedEntity")

    expected = {"filterBy": {"relatedEntity": {"cloudPlatform": {"equals": ["AWS"]}}}}
    assert result == expected


def test_apply_wiz_filter_existing_filterby():
    """Test apply_wiz_filter with existing filterBy"""
    variables = {"filterBy": {"existing": "value"}}
    result = apply_wiz_filter(variables, "CRITICAL", "severity", False)

    assert result["filterBy"]["existing"] == "value"
    assert result["filterBy"]["severity"] == ["CRITICAL"]


@pytest.mark.parametrize(
    "severity_list,expected_filter",
    [
        (["CRITICAL"], {"filterBy": {"severity": ["CRITICAL"]}}),
        (["CRITICAL", "HIGH"], {"filterBy": {"severity": ["CRITICAL", "HIGH"]}}),
        (None, {}),
    ],
)
def test_apply_severity_filter(severity_list, expected_filter):
    """Test apply_severity_filter with various inputs"""
    variables = {}
    result = apply_severity_filter(variables, severity_list)

    if severity_list:
        assert result == expected_filter
    else:
        assert result == variables


@pytest.mark.parametrize(
    "status_list,expected_filter",
    [
        (["OPEN"], {"filterBy": {"status": ["OPEN"]}}),
        (["OPEN", "IN_PROGRESS"], {"filterBy": {"status": ["OPEN", "IN_PROGRESS"]}}),
        (None, {}),
    ],
)
def test_apply_status_filter(status_list, expected_filter):
    """Test apply_status_filter with various inputs"""
    variables = {}
    result = apply_status_filter(variables, status_list)

    if status_list:
        assert result == expected_filter
    else:
        assert result == variables


@pytest.mark.parametrize(
    "issue_type_list,expected_filter",
    [
        (["TOXIC_COMBINATION"], {"filterBy": {"type": ["TOXIC_COMBINATION"]}}),
        (["TOXIC_COMBINATION", "THREAT_DETECTION"], {"filterBy": {"type": ["TOXIC_COMBINATION", "THREAT_DETECTION"]}}),
        (None, {}),
    ],
)
def test_apply_issue_type_filter(issue_type_list, expected_filter):
    """Test apply_issue_type_filter with various inputs"""
    variables = {}
    result = apply_issue_type_filter(variables, issue_type_list)

    if issue_type_list:
        assert result == expected_filter
    else:
        assert result == variables


def test_apply_all_issue_filters():
    """Test apply_all_issue_filters with all filter types"""
    variables = {}
    validated_values = {"severity": ["CRITICAL", "HIGH"], "status": ["OPEN"], "issue_type": ["TOXIC_COMBINATION"]}

    result = apply_all_issue_filters(variables, validated_values)

    assert result["filterBy"]["severity"] == ["CRITICAL", "HIGH"]
    assert result["filterBy"]["status"] == ["OPEN"]
    assert result["filterBy"]["type"] == ["TOXIC_COMBINATION"]


def test_apply_all_issue_filters_empty():
    """Test apply_all_issue_filters with empty validated values"""
    variables = {}
    validated_values = {}

    result = apply_all_issue_filters(variables, validated_values)

    assert result == variables


# ===== GET FETCH ISSUES VARIABLES TESTS =====


@pytest.mark.parametrize(
    "demisto_params,expected_uses_default",
    [
        ({}, True),
        ({"issue_type": None, "status": None, "severity": None}, True),
        ({"issue_type": "TOXIC_COMBINATION"}, False),
        ({"status": "OPEN"}, False),
        ({"severity": "CRITICAL"}, False),
        ({"issue_type": "TOXIC_COMBINATION", "status": "OPEN"}, False),
    ],
)
@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_get_fetch_issues_variables(mock_demisto, demisto_params, expected_uses_default):
    """Test get_fetch_issues_variables with various argument combinations"""
    mock_demisto.args.return_value = demisto_params
    mock_demisto.info = MagicMock()
    mock_demisto.params.return_value = demisto_params

    max_fetch = 50
    last_run = "2025-01-01T00:00:00Z"

    result = get_fetch_issues_variables(max_fetch, last_run)

    assert result["first"] == max_fetch
    assert result["filterBy"]["createdAt"]["after"] == last_run
    assert "relatedEntity" in result["filterBy"]

    if expected_uses_default:
        mock_demisto.info.assert_called_with("No issue type, status or severity provided, fetching default issues")
        assert result["filterBy"]["status"] == DEFAULT_FETCH_ISSUE_STATUS

    if demisto_params.get("issue_type"):
        assert "type" in result["filterBy"]
    if demisto_params.get("status") and not expected_uses_default:
        assert "status" in result["filterBy"]
    if demisto_params.get("severity"):
        assert "severity" in result["filterBy"]


@patch("Packs.Wiz.Integrations.Wiz.Wiz.return_error")
@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_get_fetch_issues_variables_validation_error(mock_demisto, mock_return_error):
    """Test get_fetch_issues_variables with validation error"""
    mock_demisto.params.return_value = {"issue_type": "INVALID_TYPE"}

    get_fetch_issues_variables(50, "2025-01-01T00:00:00Z")

    mock_return_error.assert_called_once()


@patch("Packs.Wiz.Integrations.WizDefend.WizDefend.demisto")
def test_get_fetch_issues_variables_copies_default_variables(mock_demisto):
    """Test that get_fetch_issues_variables properly copies default variables"""
    mock_demisto.args.return_value = {}
    mock_demisto.info = MagicMock()

    max_fetch = 100
    last_run = "2025-01-01T00:00:00Z"

    result = get_fetch_issues_variables(max_fetch, last_run)

    # Verify it includes default variables
    for key, value in PULL_ISSUES_DEFAULT_VARIABLES.items():
        assert result[key] == value

    # Verify it doesn't modify the original
    assert "first" not in PULL_ISSUES_DEFAULT_VARIABLES
    assert "statusChangedAt" not in PULL_ISSUES_DEFAULT_VARIABLES


# ===== ENUM CLASSES TESTS =====


def test_wiz_issue_type_values():
    """Test WizIssueType.values() returns expected values"""
    values = WizIssueType.values()
    expected = ["TOXIC_COMBINATION", "CLOUD_CONFIGURATION", "THREAT_DETECTION"]

    assert all(value in values for value in expected)
    assert len(values) == len(expected)


def test_wiz_severity_values():
    """Test WizSeverity.values() returns expected values"""
    values = WizSeverity.values()
    expected = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]

    assert all(value in values for value in expected)
    assert len(values) == len(expected)


def test_wiz_status_values():
    """Test WizStatus.values() returns expected values"""
    values = WizStatus.values()
    expected = ["OPEN", "IN_PROGRESS", "REJECTED", "RESOLVED"]

    assert all(value in values for value in expected)
    assert len(values) == len(expected)


# ===== VALIDATION RESPONSE TESTS =====


def test_validation_response_success():
    """Test ValidationResponse.create_success()"""
    response = ValidationResponse.create_success(["test", "value"])

    assert response.is_valid is True
    assert response.error_message is None
    assert response.value == ["test", "value"]


def test_validation_response_error():
    """Test ValidationResponse.create_error()"""
    response = ValidationResponse.create_error("Test error message")

    assert response.is_valid is False
    assert response.error_message == "Test error message"
    assert response.value is None


def test_validation_response_to_dict():
    """Test ValidationResponse.to_dict()"""
    response = ValidationResponse.create_success(["test"])
    response.severity_list = ["CRITICAL"]

    result = response.to_dict()

    assert result["is_valid"] is True
    assert result["error_message"] is None
    assert result["value"] == ["test"]
    assert result["severity_list"] == ["CRITICAL"]


# ===== MIRROR TESTS =====


@pytest.fixture
def mock_mirror_params():
    """Common demisto mock setup for mirror tests requiring direction + instance."""
    with (
        patch.object(
            demisto,
            "params",
            return_value={WizMirrorParam.DIRECTION: "Incoming", WizMirrorParam.COMMENT_TAG: "comments"},
        ),
        patch.object(demisto, "integrationInstance", return_value="Wiz_instance_1"),
    ):
        yield


def test_get_mapping_fields_command():
    """Test get_mapping_fields_command returns scheme with expected fields"""
    from Wiz import get_mapping_fields_command

    result = get_mapping_fields_command()
    # GetMappingFieldsResponse has scheme_types_mappings attribute
    assert result is not None
    schemes = result.scheme_types_mappings
    assert len(schemes) == 1
    assert schemes[0].type_name == "Wiz Issue"
    for field in WIZ_MIRRORED_FIELDS:
        assert field in schemes[0].fields


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_command(mock_check_api, _mock_get_ctx, _mock_set_ctx):
    """Test get_modified_remote_data_command extracts IDs correctly"""
    from Wiz import get_modified_remote_data_command

    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [
                    {"id": "11111111-1111-1111-1111-111111111111", "statusChangedAt": "2025-01-02T00:00:00Z"},
                    {"id": "22222222-2222-2222-2222-222222222222", "statusChangedAt": "2025-01-03T00:00:00Z"},
                ],
                "pageInfo": {"hasNextPage": False},
            }
        }
    }

    args = {"lastUpdate": "2025-01-01T00:00:00Z"}
    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        result = get_modified_remote_data_command(args)

    assert result.modified_incident_ids == [
        "11111111-1111-1111-1111-111111111111",
        "22222222-2222-2222-2222-222222222222",
    ]


@patch("Wiz.get_issue")
def test_get_remote_data_command(mock_get_issue, mock_mirror_params):
    """Test get_remote_data_command returns issue with mirror metadata and note entries"""
    from Wiz import get_remote_data_command

    mock_get_issue.return_value = [
        {
            "id": "11111111-1111-1111-1111-111111111111",
            "status": "OPEN",
            "severity": "HIGH",
            "notes": [
                {
                    "text": "New finding from team",
                    "createdAt": "2025-06-01T10:00:00Z",
                    "updatedAt": "2025-06-01T10:00:00Z",
                    "user": {"name": "Alice"},
                    "serviceAccount": None,
                }
            ],
        }
    ]

    args = {"id": "11111111-1111-1111-1111-111111111111", "lastUpdate": "2025-01-01T00:00:00Z"}
    result = get_remote_data_command(args)

    assert result.mirrored_object["id"] == "11111111-1111-1111-1111-111111111111"
    assert result.mirrored_object[WizMirrorField.DIRECTION] == "In"
    assert result.mirrored_object[WizMirrorField.INSTANCE] == "Wiz_instance_1"
    assert len(result.entries) == 1
    assert "Alice" in result.entries[0]["Contents"]


@patch("Wiz.get_issue")
def test_get_remote_data_command_issue_not_found(mock_get_issue):
    """Test get_remote_data_command with empty response"""
    from Wiz import get_remote_data_command

    mock_get_issue.return_value = {}

    args = {"id": "11111111-1111-1111-1111-111111111111", "lastUpdate": "2025-01-01T00:00:00Z"}
    result = get_remote_data_command(args)

    assert result.mirrored_object == {}
    assert result.entries == []


@patch("Wiz.get_issue")
def test_get_remote_data_skips_xsoar_mirrored_notes(mock_get_issue, mock_mirror_params):
    """Test that notes containing XSOAR_MIRROR_MARKER are skipped"""
    from Wiz import get_remote_data_command

    mock_get_issue.return_value = [
        {
            "id": "11111111-1111-1111-1111-111111111111",
            "status": "OPEN",
            "notes": [
                {
                    "text": f"(analyst): some note\n\n{XSOAR_MIRROR_MARKER}",
                    "createdAt": "2025-06-01T10:00:00Z",
                    "updatedAt": "2025-06-01T10:00:00Z",
                    "user": {"name": "Bot"},
                    "serviceAccount": None,
                },
                {
                    "text": "Genuine Wiz note",
                    "createdAt": "2025-06-01T11:00:00Z",
                    "updatedAt": "2025-06-01T11:00:00Z",
                    "user": {"name": "Bob"},
                    "serviceAccount": None,
                },
            ],
        }
    ]

    args = {"id": "11111111-1111-1111-1111-111111111111", "lastUpdate": "2025-01-01T00:00:00Z"}
    result = get_remote_data_command(args)

    assert len(result.entries) == 1
    assert "Bob" in result.entries[0]["Contents"]


@pytest.mark.parametrize(
    "limit_input,expected_first",
    [
        ("0", 1),
        ("1000", 500),
        ("invalid", 50),
    ],
)
@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_mirror_limit_validation(mock_check_api, _mock_get_ctx, _mock_set_ctx, limit_input, expected_first):
    """Test that mirror_limit is clamped to 1-500 range and handles invalid input"""
    from Wiz import get_modified_remote_data_command

    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}

    args = {"lastUpdate": "2025-01-01T00:00:00Z"}
    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: limit_input}):
        get_modified_remote_data_command(args)
    call_variables = mock_check_api.call_args[0][1]
    assert call_variables["first"] == expected_first


@patch("Wiz._get_issue_type", return_value="THREAT_DETECTION")
@patch("Wiz.reject_or_resolve_issue")
def test_handle_field_changes_status_resolved(mock_resolve, _mock_type):
    """Test _handle_field_changes with resolved status (Threat Detection issue is resolvable)"""
    from Wiz import _handle_field_changes

    mock_resolve.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"status": "resolved", "resolutionReason": "ISSUE_FIXED"})

    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111", "ISSUE_FIXED", "Status mirrored from Cortex XSOAR", "RESOLVED"
    )


@patch("Wiz.reopen_issue")
def test_handle_field_changes_status_reopen(mock_reopen):
    """Test _handle_field_changes with open status"""
    from Wiz import _handle_field_changes

    mock_reopen.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"status": "open"})

    mock_reopen.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111", reopen_note="")


@patch("Wiz.set_issue_comment")
def test_handle_outgoing_entries(mock_set_comment, mock_mirror_params):
    """Test _handle_outgoing_entries pushes notes with marker"""
    from Wiz import _handle_outgoing_entries

    mock_set_comment.return_value = {}
    entries = [{"contents": "Investigation complete", "user": "analyst@corp.com", "tags": ["comments"]}]

    _handle_outgoing_entries("11111111-1111-1111-1111-111111111111", entries)

    mock_set_comment.assert_called_once()
    call_args = mock_set_comment.call_args
    assert XSOAR_MIRROR_MARKER in call_args[1]["comment"]
    assert "analyst@corp.com" in call_args[1]["comment"]


@patch("Wiz.set_issue_comment")
def test_handle_outgoing_entries_skips_untagged(mock_set_comment, mock_mirror_params):
    """Defense-in-depth: entries WITHOUT the configured comment_tag must NOT be pushed
    to Wiz, even though XSOAR's mirror engine should already filter them upstream.
    Protects against tag-config drift and future XSOAR behavior changes."""
    from Wiz import _handle_outgoing_entries

    entries = [
        {"contents": "internal triage note", "user": "analyst", "tags": ["internal", "private"]},
        {"contents": "playbook step output", "user": "playbook", "tags": []},
        {"contents": "no tags at all", "user": "system"},  # tags key missing
    ]

    _handle_outgoing_entries("11111111-1111-1111-1111-111111111111", entries)

    mock_set_comment.assert_not_called()


@patch("Wiz.set_issue_comment")
def test_handle_outgoing_entries_respects_custom_comment_tag(mock_set_comment):
    """If a customer customizes `comment_tag`, only entries matching the CUSTOM tag
    should be pushed. An entry with the default 'comments' tag must be skipped if
    the customer has set comment_tag='wiz-mirror'."""
    from Wiz import _handle_outgoing_entries

    entries = [
        {"contents": "should push", "user": "analyst", "tags": ["wiz-mirror"]},
        {"contents": "should skip - default tag", "user": "analyst", "tags": ["comments"]},
    ]

    with patch.object(demisto, "params", return_value={WizMirrorParam.COMMENT_TAG: "wiz-mirror"}):
        _handle_outgoing_entries("11111111-1111-1111-1111-111111111111", entries)

    assert mock_set_comment.call_count == 1
    assert "should push" in mock_set_comment.call_args[1]["comment"]


@patch("Wiz.set_issue_due_date")
def test_handle_field_changes_due_date(mock_set_due):
    """Test _handle_field_changes with due date change"""
    from Wiz import _handle_field_changes

    mock_set_due.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"dueAt": "2025-12-31"})

    mock_set_due.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111", due_at="2025-12-31")


@patch("Wiz.clear_issue_due_date")
def test_handle_field_changes_clear_due_date(mock_clear_due):
    """Test _handle_field_changes clears due date when dueAt is empty string"""
    from Wiz import _handle_field_changes

    mock_clear_due.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"dueAt": ""})

    mock_clear_due.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111")


@patch("Wiz._get_issue_type", return_value="THREAT_DETECTION")
@patch("Wiz.reject_or_resolve_issue")
def test_handle_incident_closed(mock_resolve, _mock_type):
    """Test _handle_incident_closed resolves a Threat Detection issue in Wiz"""
    from Wiz import _handle_incident_closed

    mock_resolve.return_value = {}
    _handle_incident_closed("11111111-1111-1111-1111-111111111111")

    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111",
        DEFAULT_RESOLUTION_REASON,
        "Resolved from Cortex XSOAR",
        "RESOLVED",
    )


def test_update_remote_system_no_remote_id():
    """Test update_remote_system_command returns early with no remote_id"""
    from Wiz import update_remote_system_command

    result = update_remote_system_command({"remoteId": "", "data": {}, "entries": [], "incidentChanged": False})

    assert result == ""


@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_build_incidents_with_mirror_metadata(mock_demisto):
    """Test build_incidents adds mirror metadata when mirroring enabled"""
    mock_demisto.params.return_value = {
        WizMirrorParam.DIRECTION: "Incoming And Outgoing",
        WizMirrorParam.COMMENT_TAG: "comments",
    }
    mock_demisto.integrationInstance.return_value = "Wiz_instance_1"
    mock_demisto.get = demisto.get

    issue = {
        "id": "11111111-1111-1111-1111-111111111111",
        "severity": "HIGH",
        "createdAt": "2025-01-01T00:00:00Z",
        "sourceRule": {"name": "Test Rule"},
    }

    result = build_incidents(issue)

    raw = json.loads(result["rawJSON"])
    assert raw[WizMirrorField.DIRECTION] == "Both"
    assert raw[WizMirrorField.INSTANCE] == "Wiz_instance_1"
    assert raw[WizMirrorField.ID] == "11111111-1111-1111-1111-111111111111"
    assert raw[WizMirrorField.TAGS] == ["comments"]


@patch("Packs.Wiz.Integrations.Wiz.Wiz.demisto")
def test_build_incidents_without_mirror_metadata(mock_demisto):
    """Test build_incidents does NOT add mirror metadata when direction is None"""
    mock_demisto.params.return_value = {WizMirrorParam.DIRECTION: "None"}
    mock_demisto.integrationInstance.return_value = "Wiz_instance_1"
    mock_demisto.get = demisto.get

    issue = {
        "id": "11111111-1111-1111-1111-111111111111",
        "severity": "HIGH",
        "createdAt": "2025-01-01T00:00:00Z",
        "sourceRule": {"name": "Test Rule"},
    }

    result = build_incidents(issue)

    raw = json.loads(result["rawJSON"])
    assert WizMirrorField.DIRECTION not in raw
    assert WizMirrorField.INSTANCE not in raw
    assert WizMirrorField.ID not in raw


# ===== GAP #1: _fetch_all_issue_nodes multi-page pagination =====


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_multi_page(mock_check_api):
    """Test that _fetch_all_issue_nodes concatenates nodes from multiple pages and propagates cursor"""
    from Wiz import _fetch_all_issue_nodes

    page1_response = {
        "data": {
            "issues": {
                "nodes": [{"id": "issue-1"}, {"id": "issue-2"}],
                "pageInfo": {"hasNextPage": True, "endCursor": "cursor-abc"},
            }
        }
    }
    page2_response = {
        "data": {
            "issues": {
                "nodes": [{"id": "issue-3"}],
                "pageInfo": {"hasNextPage": False, "endCursor": None},
            }
        }
    }
    mock_check_api.side_effect = [page1_response, page2_response]

    result = _fetch_all_issue_nodes("some_query", {"first": 10})

    assert len(result) == 3
    assert [n["id"] for n in result] == ["issue-1", "issue-2", "issue-3"]
    # Verify cursor was propagated in the second call
    second_call_vars = mock_check_api.call_args_list[1][0][1]
    assert second_call_vars["after"] == "cursor-abc"


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_single_page(mock_check_api):
    """Test that _fetch_all_issue_nodes works with a single page (no pagination)"""
    from Wiz import _fetch_all_issue_nodes

    single_page = {
        "data": {
            "issues": {
                "nodes": [{"id": "only-issue"}],
                "pageInfo": {"hasNextPage": False, "endCursor": None},
            }
        }
    }
    mock_check_api.return_value = single_page

    result = _fetch_all_issue_nodes("some_query", {"first": 10})

    assert len(result) == 1
    assert result[0]["id"] == "only-issue"
    mock_check_api.assert_called_once()


# ===== wiz-get-issues: created_at filter + limit (WZ-114555) =====


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_max_records_truncates(mock_check_api):
    """max_records stops pagination early and truncates the result to exactly that many nodes."""
    from Wiz import _fetch_all_issue_nodes

    page1 = {
        "data": {
            "issues": {
                "nodes": [{"id": "issue-1"}, {"id": "issue-2"}, {"id": "issue-3"}],
                "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"},
            }
        }
    }
    mock_check_api.return_value = page1

    result = _fetch_all_issue_nodes("some_query", {"first": 3}, max_records=2)

    assert [n["id"] for n in result] == ["issue-1", "issue-2"]
    # The first page already satisfied the cap, so no second (paginated) call was made.
    mock_check_api.assert_called_once()


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_max_records_none_fetches_all(mock_check_api):
    """max_records=None preserves the fetch-everything behavior across pages."""
    from Wiz import _fetch_all_issue_nodes

    page1 = {"data": {"issues": {"nodes": [{"id": "i1"}, {"id": "i2"}], "pageInfo": {"hasNextPage": True, "endCursor": "c1"}}}}
    page2 = {"data": {"issues": {"nodes": [{"id": "i3"}], "pageInfo": {"hasNextPage": False, "endCursor": None}}}}
    mock_check_api.side_effect = [page1, page2]

    result = _fetch_all_issue_nodes("some_query", {"first": 10}, max_records=None)

    assert [n["id"] for n in result] == ["i1", "i2", "i3"]


@patch("Wiz._fetch_all_issue_nodes", return_value=[])
def test_get_filtered_issues_created_at_filter(mock_fetch):
    """created_after / created_before are mapped to the Wiz createdAt filter."""
    from Wiz import get_filtered_issues

    get_filtered_issues(
        entity_type="",
        resource_id="",
        severity="",
        issue_type="TOXIC_COMBINATION",
        limit=None,
        created_after="2024-01-01T00:00:00Z",
        created_before="2024-02-01T00:00:00Z",
    )

    variables = mock_fetch.call_args[0][1]
    assert variables["filterBy"]["createdAt"] == {"after": "2024-01-01T00:00:00Z", "before": "2024-02-01T00:00:00Z"}
    assert variables["filterBy"]["type"] == ["TOXIC_COMBINATION"]


@patch("Wiz._fetch_all_issue_nodes", return_value=[])
def test_get_filtered_issues_created_at_standalone(mock_fetch):
    """A date filter alone is a valid query (no entity/severity/type required)."""
    from Wiz import get_filtered_issues

    result = get_filtered_issues(
        entity_type="",
        resource_id="",
        severity="",
        issue_type="",
        limit=None,
        created_after="2024-01-01T00:00:00Z",
    )

    # Not an error string -> the call proceeded to fetch.
    assert not isinstance(result, str)
    variables = mock_fetch.call_args[0][1]
    assert variables["filterBy"]["createdAt"] == {"after": "2024-01-01T00:00:00Z"}


@patch("Wiz._fetch_all_issue_nodes", return_value=[])
def test_get_filtered_issues_limit_caps_page_and_records(mock_fetch):
    """limit sizes the page (<= API max) and is passed through as the total record cap."""
    from Wiz import get_filtered_issues

    get_filtered_issues(
        entity_type="VIRTUAL_MACHINE",
        resource_id="",
        severity="",
        issue_type="",
        limit=5,
    )

    args, kwargs = mock_fetch.call_args
    variables = args[1]
    assert variables["first"] == 5
    assert kwargs["max_records"] == 5


@patch("Wiz._fetch_all_issue_nodes", return_value=[])
def test_get_filtered_issues_limit_above_api_max_clamps_page(mock_fetch):
    """A limit larger than the API page size clamps `first` but keeps the full record cap."""
    from Wiz import get_filtered_issues, WIZ_API_LIMIT

    get_filtered_issues(
        entity_type="VIRTUAL_MACHINE",
        resource_id="",
        severity="",
        issue_type="",
        limit=WIZ_API_LIMIT + 250,
    )

    args, kwargs = mock_fetch.call_args
    assert args[1]["first"] == WIZ_API_LIMIT
    assert kwargs["max_records"] == WIZ_API_LIMIT + 250


def test_get_filtered_issues_no_params_errors(capfd):
    """With no filter at all, the command returns the usage error (now listing the date args)."""
    from Wiz import get_filtered_issues

    with capfd.disabled():
        result = get_filtered_issues(entity_type="", resource_id="", severity="", issue_type="", limit=None)

    assert isinstance(result, str)
    assert "created_after" in result
    assert "created_before" in result


@pytest.mark.parametrize("bad_limit", [0, -1, -100])
def test_get_filtered_issues_non_positive_limit_errors(capfd, bad_limit):
    """A non-positive limit is rejected up front (guards against nodes[:-n] silently dropping records)."""
    from Wiz import get_filtered_issues

    with capfd.disabled():
        result = get_filtered_issues(entity_type="VIRTUAL_MACHINE", resource_id="", severity="", issue_type="", limit=bad_limit)

    assert isinstance(result, str)
    assert "limit must be a positive integer" in result


@patch("Wiz._fetch_all_issue_nodes", return_value=[])
def test_get_filtered_issues_orders_by_severity_on_all_branches(mock_fetch):
    """Every filter branch sets SEVERITY DESC so a limit truncation keeps the most severe issues."""
    from Wiz import get_filtered_issues

    get_filtered_issues(entity_type="", resource_id="", severity="", issue_type="TOXIC_COMBINATION", limit=5)

    variables = mock_fetch.call_args[0][1]
    assert variables["orderBy"] == {"field": "SEVERITY", "direction": "DESC"}


# ===== GAP #2: _mirror_status_to_wiz for in_progress and rejected =====


@patch("Wiz.issue_in_progress")
def test_mirror_status_to_wiz_in_progress(mock_in_progress):
    """Test _mirror_status_to_wiz dispatches in_progress status"""
    from Wiz import _mirror_status_to_wiz

    mock_in_progress.return_value = {}
    _mirror_status_to_wiz("11111111-1111-1111-1111-111111111111", "in_progress", {})

    mock_in_progress.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111")


@patch("Wiz.reject_or_resolve_issue")
def test_mirror_status_to_wiz_rejected(mock_resolve):
    """Test _mirror_status_to_wiz dispatches rejected status"""
    from Wiz import _mirror_status_to_wiz

    mock_resolve.return_value = {}
    _mirror_status_to_wiz(
        "11111111-1111-1111-1111-111111111111",
        "rejected",
        {"resolutionReason": "FALSE_POSITIVE"},
    )

    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111", "FALSE_POSITIVE", "Status mirrored from Cortex XSOAR", "REJECTED"
    )


@patch("Wiz.reject_or_resolve_issue")
def test_mirror_status_to_wiz_rejected_default_reason(mock_resolve):
    """Test _mirror_status_to_wiz uses default reason when none provided"""
    from Wiz import _mirror_status_to_wiz

    mock_resolve.return_value = {}
    _mirror_status_to_wiz("11111111-1111-1111-1111-111111111111", "rejected", {})

    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111", DEFAULT_RESOLUTION_REASON, "Status mirrored from Cortex XSOAR", "REJECTED"
    )


# ===== Outgoing-mirror RESOLVED guard: only Threat Detection issues may be resolved =====


def _issue_type_response(issue_type):
    """Build a PULL_ISSUES_QUERY-shaped response carrying a single issue of the given type."""
    return {
        "data": {
            "issues": {
                "nodes": [{"id": "11111111-1111-1111-1111-111111111111", "type": issue_type}],
                "pageInfo": {"hasNextPage": False, "endCursor": ""},
            }
        }
    }


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz._get_issue_type", return_value="THREAT_DETECTION")
def test_mirror_status_to_wiz_resolved_threat_detection(mock_type, mock_resolve):
    """A mirrored 'resolved' on a Threat Detection issue DOES push RESOLVED to Wiz."""
    from Wiz import _mirror_status_to_wiz

    mock_resolve.return_value = {}
    _mirror_status_to_wiz(
        "11111111-1111-1111-1111-111111111111",
        "resolved",
        {"resolutionReason": "ISSUE_FIXED"},
    )

    mock_type.assert_called_once_with("11111111-1111-1111-1111-111111111111")
    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111", "ISSUE_FIXED", "Status mirrored from Cortex XSOAR", "RESOLVED"
    )


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz._get_issue_type", return_value="TOXIC_COMBINATION")
def test_mirror_status_to_wiz_resolved_non_threat_skipped(mock_type, mock_resolve):
    """A mirrored 'resolved' on a non-Threat-Detection issue must NOT push RESOLVED to Wiz.

    Regression for WZ-118281: previously the mirror fired RESOLVED for any type, the Wiz
    backend rejected it ('changing to resolved status is not allowed'), and the error was
    silently swallowed - leaving the Wiz issue open while the XSOAR incident closed.
    """
    from Wiz import _mirror_status_to_wiz

    _mirror_status_to_wiz(
        "11111111-1111-1111-1111-111111111111",
        "resolved",
        {"resolutionReason": "ISSUE_FIXED"},
    )

    mock_resolve.assert_not_called()


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz._get_issue_type", return_value="TOXIC_COMBINATION")
def test_handle_incident_closed_non_threat_skipped(mock_type, mock_resolve):
    """Closing an XSOAR incident mirrored to a non-Threat-Detection issue must NOT push RESOLVED."""
    from Wiz import _handle_incident_closed

    _handle_incident_closed("11111111-1111-1111-1111-111111111111")

    mock_resolve.assert_not_called()


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz.checkAPIerrors")
def test_mirror_status_to_wiz_resolved_non_threat_skipped_via_api(mock_check_api, mock_resolve):
    """End-to-end guard through the real _get_issue_type: a TOXIC_COMBINATION lookup skips RESOLVED."""
    from Wiz import _mirror_status_to_wiz

    mock_check_api.return_value = _issue_type_response("TOXIC_COMBINATION")
    _mirror_status_to_wiz("11111111-1111-1111-1111-111111111111", "resolved", {})

    mock_resolve.assert_not_called()


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz._get_issue_type", return_value=None)
def test_mirror_status_to_wiz_resolved_unknown_issue_skipped(mock_type, mock_resolve):
    """When the issue type can't be resolved (issue not found → None), the mirror must NOT push RESOLVED."""
    from Wiz import _mirror_status_to_wiz

    _mirror_status_to_wiz("11111111-1111-1111-1111-111111111111", "resolved", {})

    mock_resolve.assert_not_called()


@patch("Wiz.checkAPIerrors")
def test_get_issue_type_returns_none_when_not_found(mock_check_api):
    """_get_issue_type returns None when the API responds with no matching nodes."""
    from Wiz import _get_issue_type

    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": ""}}}}

    assert _get_issue_type("11111111-1111-1111-1111-111111111111") is None


@patch("Wiz.reject_or_resolve_issue")
@patch("Wiz._get_issue_type", return_value="TOXIC_COMBINATION")
def test_mirror_status_to_wiz_rejected_non_threat_still_pushes(mock_type, mock_resolve):
    """The guard is RESOLVED-only: REJECTED is valid for any issue type and must still be pushed."""
    from Wiz import _mirror_status_to_wiz

    mock_resolve.return_value = {}
    _mirror_status_to_wiz(
        "11111111-1111-1111-1111-111111111111",
        "rejected",
        {"resolutionReason": "FALSE_POSITIVE"},
    )

    mock_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111", "FALSE_POSITIVE", "Status mirrored from Cortex XSOAR", "REJECTED"
    )


# ===== GAP #3: _handle_field_changes with wizissueduedate key =====


@patch("Wiz.set_issue_due_date")
def test_handle_field_changes_wizissueduedate_fallback(mock_set_due):
    """Test _handle_field_changes picks up wizissueduedate when dueAt is absent"""
    from Wiz import _handle_field_changes

    mock_set_due.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"wizissueduedate": "2025-12-31"})

    mock_set_due.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111", due_at="2025-12-31")


@patch("Wiz.clear_issue_due_date")
def test_handle_field_changes_wizissueduedate_clear(mock_clear_due):
    """Test _handle_field_changes clears due date via wizissueduedate fallback key"""
    from Wiz import _handle_field_changes

    mock_clear_due.return_value = {}
    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"wizissueduedate": ""})

    mock_clear_due.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111")


# ===== GAP #4: _handle_field_changes with skip_status=True =====


@patch("Wiz._mirror_status_to_wiz")
def test_handle_field_changes_skip_status(mock_mirror):
    """Test _handle_field_changes does NOT call _mirror_status_to_wiz when skip_status=True"""
    from Wiz import _handle_field_changes

    _handle_field_changes("11111111-1111-1111-1111-111111111111", {"status": "resolved"}, skip_status=True)

    mock_mirror.assert_not_called()


@patch("Wiz._mirror_status_to_wiz")
@patch("Wiz.set_issue_due_date")
def test_handle_field_changes_skip_status_still_handles_due_date(mock_set_due, mock_mirror):
    """Test _handle_field_changes skips status but still processes due date"""
    from Wiz import _handle_field_changes

    mock_set_due.return_value = {}
    _handle_field_changes(
        "11111111-1111-1111-1111-111111111111",
        {"status": "resolved", "dueAt": "2025-12-31"},
        skip_status=True,
    )

    mock_mirror.assert_not_called()
    mock_set_due.assert_called_once_with(issue_id="11111111-1111-1111-1111-111111111111", due_at="2025-12-31")


# ===== GAP #5: update_remote_system_command full flow =====


@patch("Wiz._handle_outgoing_entries")
@patch("Wiz._handle_incident_closed")
@patch("Wiz._handle_field_changes")
def test_update_remote_system_full_flow(mock_field_changes, mock_closed, mock_entries):
    """Test update_remote_system_command with incident_changed + delta + close + entries"""
    from Wiz import update_remote_system_command

    args = {
        "remoteId": "11111111-1111-1111-1111-111111111111",
        "data": {},
        "entries": [{"contents": "Investigation note", "user": "analyst@corp.com"}],
        "incidentChanged": True,
        "delta": {"status": "resolved", "dueAt": "2025-12-31", "resolutionReason": "ISSUE_FIXED"},
        "status": 2,  # IncidentStatus.DONE
    }

    result = update_remote_system_command(args)

    assert result == "11111111-1111-1111-1111-111111111111"
    mock_field_changes.assert_called_once()
    # skip_status should be True because incident is closed
    assert mock_field_changes.call_args[1]["skip_status"] is True
    mock_closed.assert_called_once_with("11111111-1111-1111-1111-111111111111", resolution_reason="ISSUE_FIXED")
    mock_entries.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111",
        [{"contents": "Investigation note", "user": "analyst@corp.com"}],
    )


@patch("Wiz._handle_outgoing_entries")
@patch("Wiz._handle_incident_closed")
@patch("Wiz._handle_field_changes")
def test_update_remote_system_open_incident_with_delta(mock_field_changes, mock_closed, mock_entries):
    """Test update_remote_system_command with delta but NOT closed"""
    from Wiz import update_remote_system_command

    args = {
        "remoteId": "11111111-1111-1111-1111-111111111111",
        "data": {},
        "entries": [],
        "incidentChanged": True,
        "delta": {"status": "in_progress"},
        "status": 1,  # IncidentStatus.ACTIVE
    }

    result = update_remote_system_command(args)

    assert result == "11111111-1111-1111-1111-111111111111"
    mock_field_changes.assert_called_once()
    assert mock_field_changes.call_args[1]["skip_status"] is False
    mock_closed.assert_not_called()


# ===== closeReason fallback (round-3 mapper gap) =====
# The outgoing mapper declares `resolutionReason <- resolutionReason` but no XSOAR
# incident field by that name exists, so the mapper alone never carries the analyst's
# chosen close reason. _resolve_wiz_reason translates `closeReason` (built-in, always
# populated on close) so the user intent reaches Wiz instead of defaulting to WONT_FIX.


def test_resolve_wiz_reason_explicit_resolution_reason_wins():
    """Explicit delta.resolutionReason wins over data.closeReason (forward-compat)."""
    from Wiz import _resolve_wiz_reason

    assert _resolve_wiz_reason({"resolutionReason": "EXCEPTION"}, {"closeReason": "Resolved"}) == "EXCEPTION"


def test_resolve_wiz_reason_translates_xsoar_close_reasons():
    """closeReason on `data` translates via XSOAR_CLOSE_REASON_TO_WIZ when delta lacks resolutionReason."""
    from Wiz import _resolve_wiz_reason

    assert _resolve_wiz_reason({}, {"closeReason": "Resolved"}) == "ISSUE_FIXED"
    assert _resolve_wiz_reason({}, {"closeReason": "False Positive"}) == "FALSE_POSITIVE"
    assert _resolve_wiz_reason({}, {"closeReason": "Duplicate"}) == "WONT_FIX"
    assert _resolve_wiz_reason({}, {"closeReason": "Other"}) == "WONT_FIX"


def test_resolve_wiz_reason_unknown_close_reason_returns_none():
    """Unknown closeReason returns None so caller applies DEFAULT_RESOLUTION_REASON."""
    from Wiz import _resolve_wiz_reason

    assert _resolve_wiz_reason({}, {"closeReason": "SomeCustomReason"}) is None
    assert _resolve_wiz_reason(None, None) is None
    assert _resolve_wiz_reason({}, {}) is None


def test_resolve_wiz_reason_falls_back_to_delta_close_reason():
    """If data is missing but delta carries closeReason (mapper-supplied), still translate."""
    from Wiz import _resolve_wiz_reason

    assert _resolve_wiz_reason({"closeReason": "Resolved"}, None) == "ISSUE_FIXED"


@patch("Wiz._handle_outgoing_entries")
@patch("Wiz._handle_incident_closed")
@patch("Wiz._handle_field_changes")
def test_update_remote_system_close_reason_fallback_to_issue_fixed(mock_field_changes, mock_closed, mock_entries):
    """REGRESSION: closing an XSOAR incident with closeReason='Resolved' but no
    resolutionReason in delta must still send ISSUE_FIXED to Wiz, not the WONT_FIX default."""
    from Wiz import update_remote_system_command

    args = {
        "remoteId": "11111111-1111-1111-1111-111111111111",
        "data": {"closeReason": "Resolved", "closeNotes": "All cleaned up"},
        "entries": [],
        "incidentChanged": True,
        "delta": {"closingUserId": "admin"},  # mapper put nothing useful in delta
        "status": 2,  # IncidentStatus.DONE
    }

    update_remote_system_command(args)

    mock_closed.assert_called_once_with("11111111-1111-1111-1111-111111111111", resolution_reason="ISSUE_FIXED")


@patch("Wiz._handle_outgoing_entries")
@patch("Wiz._handle_incident_closed")
@patch("Wiz._handle_field_changes")
def test_update_remote_system_close_explicit_reason_overrides_close_reason(mock_field_changes, mock_closed, mock_entries):
    """Explicit resolutionReason in delta wins over closeReason in data."""
    from Wiz import update_remote_system_command

    args = {
        "remoteId": "11111111-1111-1111-1111-111111111111",
        "data": {"closeReason": "False Positive"},
        "entries": [],
        "incidentChanged": True,
        "delta": {"resolutionReason": "EXCEPTION"},
        "status": 2,
    }

    update_remote_system_command(args)

    mock_closed.assert_called_once_with("11111111-1111-1111-1111-111111111111", resolution_reason="EXCEPTION")


@patch("Wiz._get_issue_type", return_value="THREAT_DETECTION")
@patch("Wiz.reject_or_resolve_issue")
def test_mirror_status_to_wiz_resolved_uses_close_reason_fallback(mock_reject_resolve, _mock_type):
    """_mirror_status_to_wiz on 'resolved' status path also respects the closeReason fallback."""
    from Wiz import _mirror_status_to_wiz

    _mirror_status_to_wiz(
        "11111111-1111-1111-1111-111111111111",
        "resolved",
        delta={"status": "resolved"},
        data={"closeReason": "False Positive"},
    )

    mock_reject_resolve.assert_called_once_with(
        "11111111-1111-1111-1111-111111111111",
        "FALSE_POSITIVE",
        "Status mirrored from Cortex XSOAR",
        "RESOLVED",
    )


# ===== GAP #6: get_remote_data_command with service account notes =====


@patch("Wiz.get_issue")
def test_get_remote_data_command_service_account_notes(mock_get_issue, mock_mirror_params):
    """Test get_remote_data_command formats SA notes with [SA] prefix"""
    from Wiz import get_remote_data_command

    mock_get_issue.return_value = [
        {
            "id": "11111111-1111-1111-1111-111111111111",
            "status": "OPEN",
            "notes": [
                {
                    "text": "Automated scan completed",
                    "createdAt": "2025-06-01T10:00:00Z",
                    "updatedAt": "2025-06-01T10:00:00Z",
                    "user": None,
                    "serviceAccount": {"name": "WizScanner"},
                }
            ],
        }
    ]

    args = {"id": "11111111-1111-1111-1111-111111111111", "lastUpdate": "2025-01-01T00:00:00Z"}
    result = get_remote_data_command(args)

    assert len(result.entries) == 1
    assert "[SA] WizScanner" in result.entries[0]["Contents"]


# ===== GAP #7: WizMirrorDirection.from_params() Outgoing + invalid =====


def test_mirror_direction_from_params_outgoing():
    """Test WizMirrorDirection.from_params() returns 'Out' for Outgoing"""
    with patch.object(demisto, "params", return_value={WizMirrorParam.DIRECTION: "Outgoing"}):
        result = WizMirrorDirection.from_params()
    assert result == "Out"


def test_mirror_direction_from_params_invalid():
    """Test WizMirrorDirection.from_params() returns None for invalid value"""
    with patch.object(demisto, "params", return_value={WizMirrorParam.DIRECTION: "InvalidValue"}):
        result = WizMirrorDirection.from_params()
    assert result is None


# ===== GAP #8: get_resources with updated_at_before =====


@patch("Wiz.checkAPIerrors", return_value=test_get_resources_response)
def test_get_resources_with_updated_at_before(checkAPIerrors):
    """Test get_resources sends 'before' in updatedAt filter"""
    from Wiz import get_resources

    get_resources(
        search=None,
        entity_type=None,
        subscription_external_ids=None,
        provider_unique_ids=None,
        project_ids=None,
        native_types=None,
        updated_at_before="2024-06-01T00:00:00Z",
        updated_at_after=None,
    )

    variables = checkAPIerrors.call_args[0][1]
    assert variables["filterBy"]["updatedAt"] == {"before": "2024-06-01T00:00:00Z"}


@patch("Wiz.checkAPIerrors", return_value=test_get_resources_response)
def test_get_resources_with_both_updated_at(checkAPIerrors):
    """Test get_resources sends both 'before' and 'after' in updatedAt filter"""
    from Wiz import get_resources

    get_resources(
        search=None,
        entity_type=None,
        subscription_external_ids=None,
        provider_unique_ids=None,
        project_ids=None,
        native_types=None,
        updated_at_before="2024-06-01T00:00:00Z",
        updated_at_after="2024-01-01T00:00:00Z",
    )

    variables = checkAPIerrors.call_args[0][1]
    assert variables["filterBy"]["updatedAt"] == {
        "before": "2024-06-01T00:00:00Z",
        "after": "2024-01-01T00:00:00Z",
    }


# ===== GAP #9: get_resources error mentions new param names =====


def test_get_resources_error_mentions_new_params(capfd):
    """Test that the no-filter error message includes all 8 parameter names"""
    from Wiz import get_resources

    with capfd.disabled():
        res = get_resources(search=None, entity_type=None, subscription_external_ids=None, provider_unique_ids=None)
        assert "project_ids" in res
        assert "native_types" in res
        assert "updated_at_before" in res
        assert "updated_at_after" in res


# ===== GAP #10: reject_or_resolve_issue truncation =====


@patch("Wiz.checkAPIerrors", return_value=test_reject_issue_response)
def test_reject_or_resolve_issue_truncates_long_comment(mock_check_api):
    """Test that reject_or_resolve_issue truncates comments exceeding MAX_NOTE_LENGTH"""
    from Wiz import reject_or_resolve_issue, MAX_NOTE_LENGTH

    long_comment = "x" * (MAX_NOTE_LENGTH + 100)
    reject_or_resolve_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "WONT_FIX", long_comment, "RESOLVED")

    sent_note = mock_check_api.call_args[0][1]["patch"]["note"]
    assert len(sent_note) <= MAX_NOTE_LENGTH
    assert sent_note.endswith("... [truncated]")


@patch("Wiz.checkAPIerrors", return_value=test_reject_issue_response)
def test_reject_issue_sends_rejected_status_and_reason(mock_check_api):
    """Reject path must send status=REJECTED with resolutionReason and the note text."""
    from Wiz import reject_issue

    reject_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "FALSE_POSITIVE", "rejecting_for_e2e")

    variables = mock_check_api.call_args[0][1]
    assert variables["issueId"] == "12345678-2222-3333-1111-ff5fa2ff7f78"
    assert variables["patch"]["status"] == "REJECTED"
    assert variables["patch"]["resolutionReason"] == "FALSE_POSITIVE"
    assert variables["patch"]["note"] == "rejecting_for_e2e"


@patch("Wiz.checkAPIerrors", return_value=test_reject_issue_response)
def test_resolve_issue_sends_resolved_status_and_reason(mock_check_api):
    """Resolve path must send status=RESOLVED with resolutionReason and the note text."""
    # resolve_issue requires the issue type to be THREAT_DETECTION; mock _get_issue accordingly
    from unittest.mock import patch as _patch

    threat_issue = {"data": {"issues": {"nodes": [{"type": "THREAT_DETECTION"}]}}}
    with _patch("Wiz._get_issue", return_value=threat_issue):
        from Wiz import resolve_issue

        resolve_issue("12345678-2222-3333-1111-ff5fa2ff7f78", "ISSUE_FIXED", "resolving_for_e2e")

    variables = mock_check_api.call_args[0][1]
    assert variables["issueId"] == "12345678-2222-3333-1111-ff5fa2ff7f78"
    assert variables["patch"]["status"] == "RESOLVED"
    assert variables["patch"]["resolutionReason"] == "ISSUE_FIXED"
    assert variables["patch"]["note"] == "resolving_for_e2e"


# ===== _build_new_note_entries tests =====


LAST_UPDATE = "2025-01-01T00:00:00Z"


def format_as_note(text: str, time: str, author: str) -> dict[str, Any]:
    return {
        "text": text,
        "createdAt": time,
        "updatedAt": time,
        "user": {"name": author},
        "serviceAccount": None,
    }


@pytest.mark.parametrize(
    "issue,last_update,expected_count,expected_content",
    [
        pytest.param({"notes": None}, LAST_UPDATE, 0, None, id="notes_null"),
        pytest.param({"notes": []}, LAST_UPDATE, 0, None, id="notes_empty"),
        pytest.param({}, LAST_UPDATE, 0, None, id="notes_key_missing"),
        pytest.param({"notes": [format_as_note("x", "2025-06-01T10:00:00Z", "A")]}, None, 0, None, id="no_last_update"),
        pytest.param(
            {
                "notes": [
                    format_as_note("old", "2024-01-01T00:00:00Z", "Alice"),
                    format_as_note("new", "2025-06-01T10:00:00Z", "Bob"),
                ]
            },
            LAST_UPDATE,
            1,
            "Bob",
            id="filters_old_keeps_new",
        ),
    ],
)
def test_build_new_note_entries(issue, last_update, expected_count, expected_content):
    from Wiz import _build_new_note_entries

    result = _build_new_note_entries(issue, last_update)
    assert len(result) == expected_count
    if expected_content:
        assert expected_content in result[0]["Contents"]


def test_build_new_note_entries_microsecond_vs_second_precision():
    """Regression: Wiz returns microsecond-precision timestamps; XSOAR's lastUpdate
    is second-precision. Lex compare flips the order — `.` (0x2E) < `Z` (0x5A) —
    so `"2025-06-01T10:00:00.500000Z" < "2025-06-01T10:00:00Z"` lexicographically.
    A note created 0.5s AFTER lastUpdate would be silently dropped from the war room.
    The fix parses both with `datetime.fromisoformat` before comparing."""
    from Wiz import _build_new_note_entries

    last_update = "2025-06-01T10:00:00Z"  # second-precision (XSOAR style)
    issue = {
        "notes": [
            # Note created 0.5s AFTER lastUpdate, but with microsecond precision (Wiz style)
            format_as_note("new_microsecond", "2025-06-01T10:00:00.500000Z", "Alice"),
            # Note created 1s before lastUpdate — should still be filtered out
            format_as_note("old", "2025-06-01T09:59:59Z", "Bob"),
        ]
    }
    result = _build_new_note_entries(issue, last_update)

    # Pre-fix: lex compare would treat the microsecond note as OLDER and drop it (0 entries).
    # Post-fix: parsed datetime compare correctly identifies it as newer.
    assert len(result) == 1
    assert "Alice" in result[0]["Contents"]
    assert "new_microsecond" in result[0]["Contents"]


def test_build_new_note_entries_iso_with_offset():
    """ISO timestamps with explicit offset (`+00:00`) must be parsed correctly,
    even when the other side uses the `Z` shorthand. Both denote UTC."""
    from Wiz import _build_new_note_entries

    issue = {
        "notes": [
            format_as_note("newer", "2025-06-01T10:00:01+00:00", "Alice"),
            format_as_note("equal", "2025-06-01T10:00:00+00:00", "Bob"),
        ]
    }
    result = _build_new_note_entries(issue, "2025-06-01T10:00:00Z")

    assert len(result) == 1
    assert "newer" in result[0]["Contents"]


def test_parse_iso_timestamp_handles_bad_input():
    """Helper must return None for empty/invalid input rather than raising."""
    from Wiz import _parse_iso_timestamp

    assert _parse_iso_timestamp("") is None
    assert _parse_iso_timestamp(None) is None
    assert _parse_iso_timestamp("not-a-date") is None
    assert _parse_iso_timestamp("2025-06-01T10:00:00Z") is not None


# ===== _attach_mirror_metadata tests =====


def test_attach_mirror_metadata_incoming(mock_mirror_params):
    from Wiz import _attach_mirror_metadata

    issue = {"id": "test-issue-id"}
    _attach_mirror_metadata(issue)

    assert issue[WizMirrorField.DIRECTION] == "In"
    assert issue[WizMirrorField.INSTANCE] == "Wiz_instance_1"
    assert issue[WizMirrorField.ID] == "test-issue-id"
    assert issue[WizMirrorField.TAGS] == ["comments"]


def test_attach_mirror_metadata_no_direction():
    from Wiz import _attach_mirror_metadata

    with patch.object(demisto, "params", return_value={}):
        issue = {"id": "test-issue-id"}
        _attach_mirror_metadata(issue)

    assert WizMirrorField.DIRECTION not in issue


# ===== Null-safety for notes in mirroring and commands =====


@patch("Wiz.get_issue")
def test_get_remote_data_command_notes_none(mock_get_issue, mock_mirror_params):
    from Wiz import get_remote_data_command

    mock_get_issue.return_value = [{"id": "11111111-1111-1111-1111-111111111111", "status": "OPEN", "notes": None}]

    result = get_remote_data_command({"id": "11111111-1111-1111-1111-111111111111", "lastUpdate": "2025-01-01T00:00:00Z"})

    assert result.mirrored_object["id"] == "11111111-1111-1111-1111-111111111111"
    assert result.entries == []


@patch("Wiz._get_issue")
def test_clear_issue_note_notes_none(mock_get_issue):
    from Wiz import clear_issue_note

    mock_get_issue.return_value = {"data": {"issues": {"nodes": [{"notes": None}]}}}
    result = clear_issue_note("12345678-1111-2222-3333-444444444444")
    assert result is None  # no notes to delete, returns None


def test_outgoing_mapper_includes_resolution_reason():
    """Verify the outgoing mapper JSON maps resolutionReason so close reason propagates to Wiz"""
    import json
    import os

    mapper_path = os.path.join(os.path.dirname(__file__), "..", "..", "Classifiers", "classifier-mapper-outgoing-Wiz.json")
    with open(mapper_path) as f:
        mapper = json.load(f)

    fields = mapper["mapping"]["Wiz Issue"]["internalMapping"]
    assert "resolutionReason" in fields, "outgoing mapper must map resolutionReason"
    assert "status" in fields
    assert "dueAt" in fields


# ===== Mirror cursor + slim query tests (5-min Docker timeout fix) =====


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_uses_slim_query(mock_check_api, _get_ctx, _set_ctx):
    """Mirror MUST use MODIFIED_ISSUE_IDS_QUERY (id + statusChangedAt only), not the
    heavy PULL_ISSUES_QUERY. Pulling sourceRule/projects/entitySnapshot/notes for
    every modified issue when we only consume `id` is what made the function time
    out at the 5-min Docker limit on backlogs."""
    from Wiz import get_modified_remote_data_command, MODIFIED_ISSUE_IDS_QUERY, PULL_ISSUES_QUERY

    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-01-01T00:00:00Z"})

    query_used = mock_check_api.call_args[0][0]
    assert query_used == MODIFIED_ISSUE_IDS_QUERY
    assert query_used != PULL_ISSUES_QUERY
    # Slim query should NOT request expensive nested fields.
    for heavy_field in ("sourceRule", "entitySnapshot", "projects", "notes", "serviceTickets"):
        assert heavy_field not in query_used, f"slim query must not request {heavy_field}"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_single_page_only(mock_check_api, _get_ctx, _set_ctx):
    """Mirror must do exactly ONE GraphQL call per cycle, even when hasNextPage=True.
    The unbounded pagination loop in _fetch_all_issue_nodes is what blew the timeout —
    single-page-per-call ensures bounded latency. Backlog drains across cycles."""
    from Wiz import get_modified_remote_data_command

    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [{"id": f"issue-{i}", "statusChangedAt": f"2025-01-02T00:00:0{i}Z"} for i in range(3)],
                "pageInfo": {"hasNextPage": True, "endCursor": "wiz-cursor-1"},
            }
        }
    }

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "500"}):
        result = get_modified_remote_data_command({"lastUpdate": "2025-01-01T00:00:00Z"})

    # Exactly one call, no pagination follow-up
    assert mock_check_api.call_count == 1
    assert result.modified_incident_ids == ["issue-0", "issue-1", "issue-2"]


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_first_call_uses_last_update(mock_check_api, _get_ctx, set_ctx_mock):
    """First call (no saved cursor) uses XSOAR's lastUpdate as the filter."""
    from Wiz import get_modified_remote_data_command, MIRROR_CURSOR_KEY

    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [{"id": "issue-1", "statusChangedAt": "2025-01-02T12:00:00Z"}],
                "pageInfo": {"hasNextPage": False},
            }
        }
    }

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-01-01T00:00:00Z"})

    call_vars = mock_check_api.call_args[0][1]
    assert call_vars["filterBy"]["statusChangedAt"]["after"] == "2025-01-01T00:00:00Z"
    assert call_vars["orderBy"] == {"field": "STATUS_CHANGED_AT", "direction": "ASC"}
    # Cursor advanced to the page max
    saved_ctx = set_ctx_mock.call_args[0][0]
    assert saved_ctx[MIRROR_CURSOR_KEY] == "2025-01-02T12:00:00Z"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext")
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_cursor_wins_over_stale_last_update(mock_check_api, get_ctx_mock, _set_ctx):
    """When XSOAR's lastUpdate has raced ahead (because we returned a partial page in
    the previous cycle), the saved cursor — which points to the actual data we've
    drained — must take precedence. Otherwise we'd skip undrained issues."""
    from Wiz import get_modified_remote_data_command

    # XSOAR thinks we're caught up; our cursor knows we're not.
    get_ctx_mock.return_value = {"mirror_cursor": "2025-01-15T00:00:00Z"}
    mock_check_api.return_value = {
        "data": {
            "issues": {"nodes": [], "pageInfo": {"hasNextPage": False}},
        }
    }

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-01T00:00:00Z"})

    call_vars = mock_check_api.call_args[0][1]
    # Should pick the LATER of the two — XSOAR's lastUpdate, since it's newer.
    assert call_vars["filterBy"]["statusChangedAt"]["after"] == "2025-04-01T00:00:00Z"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext")
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_saved_cursor_used_when_lastupdate_older(mock_check_api, get_ctx_mock, _set_ctx):
    """The classic backlog-drain case: previous cycle returned a partial page covering
    up to T1. XSOAR's lastUpdate is still T0 (older). Cursor T1 wins, so we resume
    from T1 instead of re-fetching the chunk we already drained."""
    from Wiz import get_modified_remote_data_command

    get_ctx_mock.return_value = {"mirror_cursor": "2025-04-01T12:00:00Z"}
    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-01T00:00:00Z"})

    call_vars = mock_check_api.call_args[0][1]
    assert call_vars["filterBy"]["statusChangedAt"]["after"] == "2025-04-01T12:00:00Z"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext")
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_mixed_precision_cursor_wins(mock_check_api, get_ctx_mock, _set_ctx):
    """Mixed-precision cursor compare: saved_cursor is microsecond (Wiz), lastUpdate
    is bare-Z second-precision (XSOAR). Lex `max` would pick `:00Z` over `:00.500000Z`
    because `Z` (0x5A) > `.` (0x2E), rewinding the cursor by up to 999ms and triggering
    re-fetch of the half-second window. Datetime compare must keep the microsecond cursor."""
    from Wiz import get_modified_remote_data_command

    get_ctx_mock.return_value = {"mirror_cursor": "2025-04-01T10:00:00.500000Z"}
    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-01T10:00:00Z"})

    call_vars = mock_check_api.call_args[0][1]
    assert call_vars["filterBy"]["statusChangedAt"]["after"] == "2025-04-01T10:00:00.500000Z"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_cursor_advances_to_page_max(mock_check_api, _get_ctx, set_ctx_mock):
    """After a successful page fetch, cursor must advance to max(statusChangedAt) of
    returned nodes, not to wall-clock or any other value. Drains the backlog
    deterministically across cycles."""
    from Wiz import get_modified_remote_data_command, MIRROR_CURSOR_KEY

    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [
                    {"id": "i1", "statusChangedAt": "2025-04-23T09:00:00.000000Z"},
                    {"id": "i2", "statusChangedAt": "2025-04-23T09:30:00.000000Z"},
                    {"id": "i3", "statusChangedAt": "2025-04-23T11:30:00.123456Z"},
                ],
                "pageInfo": {"hasNextPage": True, "endCursor": "wiz-c1"},
            }
        }
    }

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "500"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-22T00:00:00Z"})

    saved_ctx = set_ctx_mock.call_args[0][0]
    assert saved_ctx[MIRROR_CURSOR_KEY] == "2025-04-23T11:30:00.123456Z"


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={"mirror_cursor": "2025-04-22T00:00:00Z"})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_empty_page_preserves_cursor(mock_check_api, _get_ctx, set_ctx_mock):
    """Empty result page must NOT touch the cursor (no setIntegrationContext call).
    Otherwise an upstream blip that returns no data could rewind state."""
    from Wiz import get_modified_remote_data_command

    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        result = get_modified_remote_data_command({"lastUpdate": "2025-04-01T00:00:00Z"})

    assert result.modified_incident_ids == []
    set_ctx_mock.assert_not_called()


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={"mirror_cursor": "2025-04-23T12:00:00Z"})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_does_not_rewind_cursor(mock_check_api, _get_ctx, set_ctx_mock):
    """If the page max is OLDER than the saved cursor (shouldn't happen in practice
    given ASC ordering, but defend anyway), cursor must NOT rewind."""
    from Wiz import get_modified_remote_data_command

    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [{"id": "i-old", "statusChangedAt": "2025-04-23T10:00:00Z"}],
                "pageInfo": {"hasNextPage": False},
            }
        }
    }

    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "50"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-23T11:00:00Z"})

    set_ctx_mock.assert_not_called()


@patch("Wiz.demisto.setIntegrationContext")
@patch("Wiz.demisto.getIntegrationContext", return_value={})
@patch("Wiz.checkAPIerrors")
def test_get_modified_remote_data_microsecond_tie_known_loss(mock_check_api, _get_ctx, set_ctx_mock):
    """KNOWN LIMITATION (documented, not fixed): if the LAST node in page N and the
    FIRST node of page N+1 share the same microsecond statusChangedAt, the page N+1
    issue is silently skipped — the `statusChangedAt.after` filter is exclusive.

    To upgrade to lossless: persist Wiz's GraphQL pageInfo.endCursor token across
    calls (also pin filterBy.statusChangedAt.after to the original value). Deferred
    pending evidence customers actually hit this in the wild.

    This test pins the current behavior so a future change is forced to revisit."""
    from Wiz import get_modified_remote_data_command, MIRROR_CURSOR_KEY

    # Cycle 1: page returns 2 issues, both at the SAME microsecond timestamp.
    tied_ts = "2025-04-23T11:30:00.123456Z"
    mock_check_api.return_value = {
        "data": {
            "issues": {
                "nodes": [
                    {"id": "tied-A", "statusChangedAt": tied_ts},
                    {"id": "tied-B", "statusChangedAt": tied_ts},
                ],
                "pageInfo": {"hasNextPage": True, "endCursor": "wiz-c1"},
            }
        }
    }
    with patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "2"}):
        get_modified_remote_data_command({"lastUpdate": "2025-04-22T00:00:00Z"})

    saved = set_ctx_mock.call_args[0][0]
    assert saved[MIRROR_CURSOR_KEY] == tied_ts

    # Cycle 2: a 3rd issue ALSO has tied_ts. Wiz's `status_changed_at > tied_ts`
    # would EXCLUDE it. We document this by asserting the filter sent.
    set_ctx_mock.reset_mock()
    mock_check_api.return_value = {"data": {"issues": {"nodes": [], "pageInfo": {"hasNextPage": False}}}}
    with (
        patch.object(demisto, "params", return_value={WizMirrorParam.LIMIT: "2"}),
        patch("Wiz.demisto.getIntegrationContext", return_value={MIRROR_CURSOR_KEY: tied_ts}),
    ):
        get_modified_remote_data_command({"lastUpdate": "2025-04-22T00:00:00Z"})

    call_vars = mock_check_api.call_args[0][1]
    # The filter sends the tied timestamp; the exclusive filter will skip any 3rd tied issue.
    # If this assertion ever changes, the cursor approach has been upgraded — re-check
    # whether the documented loss case is still real.
    assert call_vars["filterBy"]["statusChangedAt"]["after"] == tied_ts


# ===== Time-budget tests for _fetch_all_issue_nodes (protects fetch_incidents + get_issues) =====


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_respects_deadline(mock_check_api):
    """When the wall-clock budget is exhausted, the helper must stop paginating and
    return the partial result. This prevents Docker from killing the script at 5min
    with 'Command did not run' and losing all accumulated work."""
    from Wiz import _fetch_all_issue_nodes

    page_with_more = lambda i: {  # noqa: E731
        "data": {
            "issues": {
                "nodes": [{"id": f"issue-{i}"}],
                "pageInfo": {"hasNextPage": True, "endCursor": f"c-{i}"},
            }
        }
    }
    # 5 pages all claiming hasNextPage; with deadline_seconds=0 the loop should bail
    # immediately after the first page.
    mock_check_api.side_effect = [page_with_more(i) for i in range(5)]

    result = _fetch_all_issue_nodes("q", {"first": 1}, deadline_seconds=0)

    assert len(result) == 1
    assert result[0]["id"] == "issue-0"
    # Only the first call was made; the budget breaker fired before the second.
    assert mock_check_api.call_count == 1


@patch("Wiz.checkAPIerrors")
def test_fetch_all_issue_nodes_completes_within_budget(mock_check_api):
    """Sanity: when budget is generous, all pages are fetched as before."""
    from Wiz import _fetch_all_issue_nodes

    pages = [
        {"data": {"issues": {"nodes": [{"id": "a"}], "pageInfo": {"hasNextPage": True, "endCursor": "c1"}}}},
        {"data": {"issues": {"nodes": [{"id": "b"}], "pageInfo": {"hasNextPage": False}}}},
    ]
    mock_check_api.side_effect = pages

    result = _fetch_all_issue_nodes("q", {"first": 1}, deadline_seconds=300)

    assert [n["id"] for n in result] == ["a", "b"]
    assert mock_check_api.call_count == 2


# ===== Schema-consistency: mirror fields ↔ outgoing mapper ↔ pack IncidentFields =====
# These tests catch the bug class behind the close-reason mirror fix: declaring a mirror source field
# (in WIZ_MIRRORED_FIELDS or in the outgoing mapper's `simple:` source) that does not
# exist as either an XSOAR system field or a pack-defined custom field. When that
# happens, XSOAR populates nothing in the mirror delta and the field silently
# defaults at the destination. Unit tests that hand-build delta dicts cannot catch
# this because they assume the field arrives.

import pathlib  # noqa: E402

_PACK_ROOT = pathlib.Path(__file__).resolve().parents[2]
_INCIDENT_FIELDS_DIR = _PACK_ROOT / "IncidentFields"
_OUTGOING_MAPPER = _PACK_ROOT / "Classifiers" / "classifier-mapper-outgoing-Wiz.json"

# XSOAR system fields that are always present on incidents and can be referenced as
# `simple:` sources without a pack-level IncidentField definition. Keep this list
# tight — adding to it has the same effect as the bug we're guarding against.
# Reference: Cortex XSOAR built-in incident fields.
_XSOAR_SYSTEM_INCIDENT_FIELDS = {
    "status",
    "closeReason",
    "closeNotes",
    "closingUserId",
    "owner",
    "severity",
    "name",
    "type",
    "dueDate",
    "details",
    "occurred",
    "modified",
    "labels",
}

# Sources that are intentional mapper destinations rather than XSOAR field reads —
# e.g. `resolutionReason` is the Wiz-side enum name. The runtime backstops it via
# `closeReason` translation in `_resolve_wiz_reason`. Document each exemption with
# the reason and the runtime guard, so a future audit can prune the list.
_KNOWN_PHANTOM_SOURCES = {
    # name: (where_referenced, why_exempt)
    "resolutionReason": (
        "outgoing mapper + WIZ_MIRRORED_FIELDS",
        "Wiz API enum, no matching XSOAR field. Runtime backstop: _resolve_wiz_reason "
        "translates `closeReason` (built-in) via XSOAR_CLOSE_REASON_TO_WIZ. Remove "
        "this exemption only after either (a) a `wizresolutionreason` IncidentField "
        "is added and the mapper is updated to use it, or (b) the mapper line and "
        "WIZ_MIRRORED_FIELDS entry are removed entirely.",
    ),
    # `notes` is mirrored as war-room entries (parsed_args.entries), not via delta.
    # It appears in WIZ_MIRRORED_FIELDS only to advertise the capability via
    # get_mapping_fields_command. No mapper source references it.
    "notes": (
        "WIZ_MIRRORED_FIELDS only",
        "Not delta-driven; pushed via outgoing entries (see _handle_outgoing_entries). "
        "Listed in WIZ_MIRRORED_FIELDS for capability advertisement only.",
    ),
    # `dueAt` is the Wiz API name. The outgoing mapper rewrites the XSOAR custom
    # field `wizissueduedate` into this key, so it is a legitimate destination
    # name even though no XSOAR field shares it.
    "dueAt": (
        "WIZ_MIRRORED_FIELDS + outgoing mapper destination",
        "Wiz API name, populated by the outgoing mapper from `wizissueduedate`. "
        "Runtime checks both keys: `delta.get('dueAt')` then `delta.get('wizissueduedate')`.",
    ),
}


def _load_pack_custom_field_cli_names():
    """Collect every cliName declared by `Packs/Wiz/IncidentFields/incidentfield-*.json`."""
    cli_names = set()
    for field_file in _INCIDENT_FIELDS_DIR.glob("incidentfield-*.json"):
        with open(field_file) as f:
            data = json.load(f)
        cli_name = data.get("cliName")
        if cli_name:
            cli_names.add(cli_name)
    assert cli_names, f"No incident fields loaded from {_INCIDENT_FIELDS_DIR}; test setup is broken."
    return cli_names


def _iter_outgoing_mapper_sources():
    """Yield (destination, simple_source) for every leaf mapping in the outgoing mapper.

    Skips entries whose `simple` is empty (those use `complex` transformers, which
    aren't a flat field-name reference and aren't in scope for this guard).
    """
    with open(_OUTGOING_MAPPER) as f:
        mapper = json.load(f)
    for incident_type, scheme in mapper.get("mapping", {}).items():
        for destination, mapping in scheme.get("internalMapping", {}).items():
            simple = mapping.get("simple")
            if simple:
                yield incident_type, destination, simple


def test_outgoing_mapper_sources_resolve_to_real_fields():
    """REGRESSION: every `simple:` source in the outgoing mapper must be either
    an XSOAR system field, a pack-defined custom field cliName, or a documented
    phantom backstopped at runtime. The original bug declared
    `resolutionReason <- resolutionReason` against a non-existent source field;
    XSOAR delivered nothing in the delta and Wiz received the WONT_FIX default."""
    pack_cli_names = _load_pack_custom_field_cli_names()
    valid_sources = pack_cli_names | _XSOAR_SYSTEM_INCIDENT_FIELDS

    unresolved = []
    for incident_type, destination, simple in _iter_outgoing_mapper_sources():
        if simple in valid_sources:
            continue
        if simple in _KNOWN_PHANTOM_SOURCES:
            continue
        unresolved.append(f"{incident_type}.{destination} <- {simple!r}")

    assert not unresolved, (
        "Outgoing mapper references field name(s) that do not exist as XSOAR system "
        "fields, pack-defined IncidentFields, or known-exempt phantoms:\n  " + "\n  ".join(unresolved) + "\n\nFix options:\n"
        "  1. Add an `incidentfield-*.json` defining the cliName, OR\n"
        "  2. Change the mapper `simple:` to an existing field, OR\n"
        "  3. Add a documented entry to `_KNOWN_PHANTOM_SOURCES` if a runtime fallback exists."
    )


def test_wiz_mirrored_fields_resolve_to_real_fields():
    """REGRESSION: every name in WIZ_MIRRORED_FIELDS (advertised to XSOAR via
    get_mapping_fields_command) must be either an XSOAR system field, a pack-defined
    custom field cliName, or a documented phantom. Listing a phantom name here
    misleads admins who try to map it in the UI."""
    pack_cli_names = _load_pack_custom_field_cli_names()
    valid_sources = pack_cli_names | _XSOAR_SYSTEM_INCIDENT_FIELDS

    unresolved = [field for field in WIZ_MIRRORED_FIELDS if field not in valid_sources and field not in _KNOWN_PHANTOM_SOURCES]

    assert not unresolved, (
        f"WIZ_MIRRORED_FIELDS contains name(s) with no matching XSOAR or pack field "
        f"definition: {unresolved}. Either add the field, remove the entry, or "
        f"document an exemption in `_KNOWN_PHANTOM_SOURCES`."
    )


def test_known_phantom_exemptions_are_actually_referenced():
    """Hygiene check: every entry in `_KNOWN_PHANTOM_SOURCES` must still be referenced
    somewhere (mapper or WIZ_MIRRORED_FIELDS). If an exemption stops being needed,
    the entry should be removed so the allow-list doesn't decay into a junk drawer."""
    referenced = set(WIZ_MIRRORED_FIELDS)
    for _, _, simple in _iter_outgoing_mapper_sources():
        referenced.add(simple)

    stale = [name for name in _KNOWN_PHANTOM_SOURCES if name not in referenced]
    assert not stale, f"Phantom exemption(s) no longer referenced anywhere — remove from " f"_KNOWN_PHANTOM_SOURCES: {stale}"


def test_check_api_errors_wraps_request_timeout(mocker):
    """REGRESSION: heavy queries (e.g. TOXIC_COMBINATION issue_type) used to block
    indefinitely until the 5-min Docker hard-kill because requests.post had no
    timeout. checkAPIerrors must now bound the call with API_REQUEST_TIMEOUT and
    raise an actionable error on timeout."""
    import requests as _requests
    import Wiz as _wiz

    mocker.patch.object(_wiz, "TOKEN", "fake-token")
    mocker.patch.object(_wiz.requests, "post", side_effect=_requests.Timeout("simulated"))

    with pytest.raises(Exception, match=r"Wiz API request timed out after \d+s"):
        _wiz.checkAPIerrors(query="query {}", variables={})


def test_get_token_wraps_request_timeout(mocker):
    """Auth POST must also be bounded by API_REQUEST_TIMEOUT and surface a
    clear actionable error rather than hanging."""
    import requests as _requests
    import Wiz as _wiz

    mocker.patch.object(_wiz, "AUTH_E", "https://auth.app.wiz.io/oauth/token")
    mocker.patch.object(_wiz.requests, "post", side_effect=_requests.Timeout("simulated"))

    with pytest.raises(Exception, match=r"Wiz authentication request timed out after \d+s"):
        _wiz.get_token()