OpenAi ChatGPT v3

This integration assists security professionals with security investigations, threat hunting, and anomaly detection by leveraging OpenAI GPT models' natural language conversation capabilities.

Messaging and Conferencing · OpenAI

Details

IDOpenAi ChatGPT v3
ProviderOpenAI
CategoryMessaging and Conferencing
From Version6.0.0
Docker Imagedemisto/parse-emails:0.1.48.10120494
Supported ModulesAgentix XSIAM

README

OpenAI GPT

Instance Configuration

  • Generate an API Key

    1. Sign up or log in to OpenAI developer platform.
    2. Generate a new API key at OpenAI developer platform - api-keys.
  • Choose a GPT model to interact with

    1. This integration supports only the ‘Chat Completions’ endpoint. Therefore, you can only configure models that support this endpoint (https://api.openai.com/v1/chat/completions).

    2. For tasks requiring deep understanding and extensive inputs, opt for more advanced models (e.g. gpt-4). These models offer a larger context window, allowing them to process bigger documents, and provide more refined and comprehensive responses.
      The more elementary models (e.g. gpt-3.5) often provide shallower answers and input analysis.
      Refer to Models overview for more information.

  • Text generation setting (Optional)

    1. max-tokens: The maximum number of tokens that can be generated for the response. (Allows controlling tokens’ consumption). Default: unset.
    2. temperature: Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. It is generally recommended altering this or top_p but not both. Default: 1.
    3. top_p: Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Lower values (closer to 0) result in more focused outputs, while higher values (closer to 1) increase diversity. It is generally recommended altering this or temperature but not both. Default: unset.
  • Event Collector — Generate API Keys

    1. Admin API Key (required for OpenAI Audit logs): generate from the OpenAI Platform admin console. Used to call /v1/organization/audit_logs.
    2. Compliance API Key (required for any Compliance event type): generate from the ChatGPT Platform. Used to call /v1/compliance/workspaces/{workspace_id}/....
    3. Workspace ID (required for any Compliance event type): the UUID of the compliance workspace whose events you want to collect.
  • Event Collector — Select event types to fetch

    Toggle Fetch events, then select one or more Events types to fetch:

    User-facing label Source Required credentials
    OpenAI Audit logs OpenAI Platform — Admin API Admin API Key
    Conversation Messages ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Apps ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Apps Auth ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Compliance Audit ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Auth ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Codex ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    ChatGPT ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Codex Security ChatGPT Platform — Compliance API Compliance API Key + Workspace ID
    Workspace Agents ChatGPT Platform — Compliance API Compliance API Key + Workspace ID

    Selecting an event type without its matching credentials raises an informative error at instance test time, naming the missing parameter.

  • Event Collector — Datasets

    Each Event Collector stream lands in its own Cortex dataset:

    Stream Vendor Product Dataset
    OpenAI Audit logs openai chatgpt_audit openai_chatgpt_audit_raw
    Compliance logs (all) openai chatgpt_compliance openai_chatgpt_compliance_raw
  • Event Collector — Tuning (Optional)

    Parameter Default Description
    Maximum number of OpenAI Audit events per fetch 1000 Cap on Audit events ingested per fetch cycle.
    Maximum number of Compliance events per fetch 900 Cap on Compliance events ingested per fetch cycle.
    Events Fetch Interval 1 minute How often the scheduled fetch runs.
    ChatGPT Server URL https://api.chatgpt.com Base URL of the ChatGPT Compliance API. Override only for non-default tenants.
  • Click ‘Test’

Commands

You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.

gpt-send-message


Send a message as a prompt to the GPT model.

!gpt-send-message message="<MESSAGE_TEXT>"

Input

Argument Name Description Required
message The message to send to the GPT model wrapped with quotes. Yes
reset_conversation_history Whether to reset conversation history or keep it as context for the sent message. (Conversation history is not reset by default). No
max_tokens The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. No
temperature Sets the randomness in responses. Overrides text generation setting for the specific message sent. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. No

gpt-check-email-body


Check email body for possible security issues.

!gpt-check-email-body entryId="<ENTRY_ID_OF_UPLOADED_EML_FILE>"

Input

Argument Name Description Required
entryId Entry ID of an uploaded .eml file from the context window. Yes
additionalInstructions Provide additional instructions for the GPT model when analyzing the email body. No
max_tokens The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. No
temperature Sets the randomness in responses. Overrides text generation setting for the specific message sent. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. No

gpt-check-email-header


Check email body for possible security issues.

!gpt-check-email-header entryId="<ENTRY_ID_OF_UPLOADED_EML_FILE>"

Input

Argument Name Description Required
entryId Entry ID of an uploaded .eml file from context window. Yes
additionalInstructions Provide additional instructions for the GPT model when analyzing the email headers. No
max_tokens The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. No
temperature Sets the randomness in responses. Overrides text generation setting for the specific message sent. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. No

gpt-analyze-email-header


Analyze email headers for potential security issues using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-check-email-header which uses the Chat Completions API).

!gpt-analyze-email-header entry_id="3@123" additional_instructions="Pay close attention to SPF/DKIM."

Input

Argument Name Description Required
entry_id Entry ID of an uploaded .eml file. Yes
additional_instructions Additional instructions or security issue to focus on. Substituted into the prompt template. No
max_tokens The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. No
temperature Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. No
reasoning_effort Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. No

Context Output

Path Type Description
OpenAiChatGPTV3.Response Unknown The conversation state including the response_id.
OpenAiChatGPTV3.Response.user String The prompt sent to the model.
OpenAiChatGPTV3.Response.assistant String The assistant response text.
OpenAiChatGPTV3.Response.response_id String The OpenAI response ID.

Human Readable Output

Two war-room entries are produced:

  1. A table of the parsed email headers.
  2. The AI verdict followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.

gpt-analyze-email-body


Analyze email body for potential security risks using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-check-email-body which uses the Chat Completions API).

!gpt-analyze-email-body entry_id="3@123"

Input

Argument Name Description Required
entry_id Entry ID of an uploaded .eml file. Yes
additional_instructions Additional instructions or security issue to focus on. Substituted into the prompt template. No
max_tokens The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. No
temperature Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. No
reasoning_effort Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. No

Context Output

Path Type Description
OpenAiChatGPTV3.Response Unknown The conversation state including the response_id.
OpenAiChatGPTV3.Response.user String The prompt sent to the model.
OpenAiChatGPTV3.Response.assistant String The assistant response text.
OpenAiChatGPTV3.Response.response_id String The OpenAI response ID.

Human Readable Output

Two war-room entries are produced:

  1. A table of the parsed email body (text and HTML).
  2. The AI verdict followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.

gpt-create-soc-email-template


Create an email template out of the conversation context to be sent from the SOC.

!gpt-create-soc-email-template

Input

Argument Name Description Required
additionalInstructions Provide additional instructions for the GPT model when analyzing the email headers. No
max_tokens The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. No
temperature Sets the randomness in responses. Overrides text generation setting for the specific message sent. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. No

gpt-draft-soc-email


Draft a SOC email template using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-create-soc-email-template which uses the Chat Completions API). Consumes prior conversation context by design (e.g. from a preceding gpt-analyze-email-body call).

Cortex XSOAR sequence (typical phishing flow)

!gpt-analyze-email-body entry_id="3@123"
…assistant returns analysis…
!gpt-draft-soc-email additional_instructions="Notify the user the email was quarantined."

Input

Argument Name Description Required
additional_instructions Specific issue or focus area to weave into the template. Substituted into the prompt template. No
max_tokens The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. No
temperature Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. No
top_p Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. No
reasoning_effort Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. No

Context Output

Path Type Description
OpenAiChatGPTV3.Response Unknown The conversation state including the response_id.
OpenAiChatGPTV3.Response.user String The prompt sent to the model.
OpenAiChatGPTV3.Response.assistant String The assistant response text.
OpenAiChatGPTV3.Response.response_id String The OpenAI response ID.

Human Readable Output

Two war-room entries are produced:

  1. The SOC email template context output (replace_existing=True — running twice overwrites the previous draft).
  2. The AI-generated template followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.

openai-get-events


Manually fetch a bounded batch of Audit and/or Compliance events for development/debugging. Does NOT advance the persisted last_run cursor, so it is safe to run against production tenants. Use should_push_events=true to additionally ingest the fetched events into the matching Cortex dataset.

Base Command

openai-get-events

Input

Argument Name Description Required
event_type The event type(s) to fetch. Comma-separated list. Possible values: OpenAI Audit logs, Conversation Messages, Apps, Apps Auth, Compliance Audit, Auth, Codex, ChatGPT, Codex Security, Workspace Agents. Defaults to the values configured in the integration parameters. No
limit Maximum number of events to return per stream. Default: 50. No
start_time Lookback start time for the fetch. Supports ISO 8601 or relative time (e.g., 3 days ago, 2099-01-01T00:00:00Z). No
should_push_events If true, the command also pushes the retrieved events to Cortex (Audit -> openai_chatgpt_audit_raw, Compliance -> openai_chatgpt_compliance_raw). Possible values: true, false. Default: false. No

Context Output

Path Type Description
OpenAI.Event.id String The unique identifier of the event.
OpenAI.Event._event_type String The upstream event_type for Compliance events. Left empty for Audit events.
OpenAI.Event.source_log_type String The source log type used by downstream parsing rules.
OpenAI.Event._time Date The event timestamp in ISO 8601 format.

Human Readable Output

OpenAI GPT Events

id _event_type source_log_type _time
FAKE_AUDIT_EVENT_001   openai_audit_logs 2099-01-01T00:00:00Z
FAKE_LISTING_002 AUDIT_LOG compliance_audit_log 2099-01-02T00:00:00Z

gpt-create-response


Sends a message to the OpenAI Responses API and receives the generated response. This command uses the Responses API which is recommended for all new projects (instead of gpt-send-message which uses the Chat Completions API). Supports multi-turn conversations via previous_response_id, reasoning effort control for o-series and gpt-5 models, and background execution.

Base Command

gpt-create-response

Input

Argument Name Description Required
message The user message to send. Required
reset_conversation_history Whether to discard the existing conversation context and start fresh. Possible values are: yes, no. Default is no. Optional
max_tokens The maximum number of output tokens. Falls back to instance config. Maps internally to the API body field max_output_tokens. Optional
temperature The randomness level in responses. Falls back to instance config. Range 0-2. Lower values produce more deterministic outputs, while higher values increase variety. Optional
top_p The nucleus sampling threshold. Falls back to instance config. Range 0-1. Lower values result in more focused outputs, while higher values increase diversity. Optional
reasoning_effort The reasoning effort level. Honored only for reasoning families (o1, o3, o4, gpt-5); silently dropped on others. Default medium. Possible values are: none, minimal, low, medium, high, xhigh. Optional
background Whether to run the model response in the background. When true, the command uses polling to wait for the response to complete. Possible values are: true, false. Optional
compact_threshold The token threshold at which compaction should be triggered for this entry. Minimum 1000. Optional
model The model to use. Use the gpt-list-models command to see available models. Falls back to instance config. Optional

Context Output

Path Type Description
OpenAiChatGPTV3.Response Unknown The conversation state, which includes the response_id for multi-turn continuity.
OpenAiChatGPTV3.Response.user String The user message sent.
OpenAiChatGPTV3.Response.assistant String The assistant response text.
OpenAiChatGPTV3.Response.response_id String The OpenAI response ID used for multi-turn conversation continuity.

gpt-list-models


Lists all models available to the configured API key. Lets users discover models per their actual API-key tier without redeploying the integration when OpenAI ships new ones.

Base Command

gpt-list-models

Input

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

Context Output

Path Type Description
OpenAiChatGPTV3.Model.Id String The model identifier (e.g., gpt-4, gpt-3.5-turbo).
OpenAiChatGPTV3.Model.Created Number The Unix timestamp when the model was created.
OpenAiChatGPTV3.Model.OwnedBy String The organization or entity that owns the model.

gpt-create-moderation


Runs text or an image through the OpenAI Moderations API and returns per-category flagging results. Exactly one of text, entry_id, or image_url must be provided.

Base Command

gpt-create-moderation

Input

Argument Name Description Required
text A comma-separated list of text strings to moderate. Exactly one of text, entry_id, or image_url must be provided. Optional
entry_id The war-room entry ID of an uploaded image file. The file is base64-encoded internally and posted as a data URL. Exactly one of text, entry_id, or image_url must be provided. Optional
image_url The publicly reachable HTTP(S) URL of an image (limited to 20 MB). Exactly one of text, entry_id, or image_url must be provided. Optional
model The moderation model to use. Possible values are: omni-moderation-latest, omni-moderation-2024-09-26. Default is omni-moderation-latest. Optional

Context Output

Path Type Description
OpenAiChatGPTV3.Moderation.Input.input_type String The type of input that was moderated (text, image, or image_url).
OpenAiChatGPTV3.Moderation.Input.input_value String The value of the input that was moderated.
OpenAiChatGPTV3.Moderation.Flagged Boolean Whether the content was flagged by the moderation model.
OpenAiChatGPTV3.Moderation.Categories Unknown The object of boolean values indicating which categories were flagged.
OpenAiChatGPTV3.Moderation.CategoryScores Unknown The object of float values indicating the confidence score for each category.

<~PLATFORM>

License Requirements

The following configuration parameters require the Cortex XSIAM license:

  • Fetch events

</~PLATFORM>

Configuration parameters

  • url — Server URL (required)
  • chatgpt_api_url — ChatGPT Server URL
  • apikey
  • admin_api_key — Admin API Key
  • compliance_api_key — Compliance API Key
  • workspace_id — Workspace ID
  • model-select — Model
  • model-freetext — Model (Optional - overrides selected choice)
  • max_tokens — Max tokens
  • temperature — Temperature
  • top_p — Top P
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetchEvents — Fetch events
  • event_types_to_fetch — Events types to fetch
  • audit_max_fetch — Maximum number of OpenAI Audit events per fetch
  • compliance_max_fetch — Maximum number of Compliance events per fetch
  • eventFetchInterval — Events Fetch Interval

Commands (11)

  • gpt-analyze-email-body

    Analyzes email body for potential security risks using the OpenAI Responses API. This is the Responses-API counterpart of gpt-check-email-body (which uses the Chat Completions API).

  • gpt-analyze-email-header

    Analyzes email headers for potential security issues using the OpenAI Responses API. This is the Responses-API counterpart of gpt-check-email-header (which uses the Chat Completions API).

  • gpt-check-email-body

    Checks the email body for possible security issues. Enables you to ask subsequent questions on the provided information using the 'gpt-send-message' command, and resets the conversation context by default.

  • gpt-check-email-header

    Checking email header for possible security issues. It is possible to keep asking questions on the provided info using 'gpt-send-message'. Resets conversation context by default.

  • gpt-create-moderation

    Runs text or an image through the OpenAI Moderations API and returns per-category flagging results. Exactly one of text, entry_id, or image_url must be provided.

  • gpt-create-response

    Sends a message to the OpenAI Responses API and receives the generated response. This command uses the Responses API which is recommended for all new projects (instead of gpt-send-message which uses the Chat Completions API). Supports multi-turn conversations via previous_response_id, reasoning effort control for o-series and gpt-5 models, and background execution.

  • gpt-create-soc-email-template

    Create an email template out of the conversation context to be sent from the SOC.

  • gpt-draft-soc-email

    Drafts a SOC email template using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-create-soc-email-template which uses the Chat Completions API). Consumes prior conversation context by design (e.g. from a preceding gpt-analyze-email-body call).

  • gpt-list-models

    Lists all models available to the configured API key. Lets users discover models per their actual API-key tier without redeploying the integration when OpenAI ships new ones.

  • gpt-send-message

    Send a plain message to the selected GPT model and receive the generated response.

  • openai-get-events

    Manually retrieves events from OpenAI. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.

import json
from datetime import datetime, UTC
from typing import cast

import pytest
from CommonServerPython import *
import OpenAiChatGPTV3 as module
from OpenAiChatGPTV3 import (
    CollectorParams,
    ComplianceEvent,
    Config,
    EmailParts,
    EventType,
    LastRunKey,
    OpenAiClient,
    SourceLogType,
    _build_completed_response_result,
    _build_response_readable_output,
    _build_responses_api_body,
    _extract_credential,
    _parse_json_or_concatenated,
    analyze_email_body_command,
    analyze_email_header_command,
    check_email_part,
    create_response_command,
    deduplicate_events,
    draft_soc_email_command,
    enrich_audit_event,
    enrich_compliance_event,
    event_id,
    extract_assistant_message,
    extract_response_output_text,
    fetch_audit_logs,
    fetch_compliance_logs,
    get_email_parts,
    list_models_command,
    create_moderation_command,
    validate_create_moderation_args,
    _entry_id_to_data_url,
    parse_collector_params,
    parse_concatenated_json,
    parse_event_types_to_fetch,
    parse_first_fetch_to_datetime,
    parse_integration_params,
    selected_audit_enabled,
    selected_compliance_event_types,
    send_message_command,
    validate_event_types_credentials_correlation,
)


def util_load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.loads(f.read())


def util_load_text(path: str) -> str:
    with open(path) as f:
        return f.read()


# region Existing tests - GPT chat / email
def test_extract_assistant_message():
    """Tests extraction from a valid response with choices and message."""

    mock_response = util_load_json("test_data/mock_response.json")
    extracted_message = extract_assistant_message(response=mock_response)
    assert extracted_message == "Hello! How can I assist you today?"


@pytest.mark.parametrize("entry_id, should_raise_error", [("VALID_ENTRY_ID", False), ("INVALID_ENTRY_ID", True), ("", True)])
def test_get_email_parts(mocker, entry_id, should_raise_error):
    """Tests email parsing and parts extraction."""

    def mock_file(_entry_id: str):
        if _entry_id == "VALID_ENTRY_ID":
            return {"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"}
        elif _entry_id == "INVALID_ENTRY_ID":
            return {"path": "./test_data/dummy_file.txt", "name": "dummy_file.txt"}
        return None

    mocker.patch.object(demisto, "getFilePath", side_effect=mock_file)
    if should_raise_error:
        with pytest.raises(Exception):
            get_email_parts(entry_id=entry_id)
    else:
        headers, text_body, html_body, file_name = get_email_parts(entry_id=entry_id)
        assert headers == util_load_json("test_data/expected_headers.json")
        assert text_body == "Body of the text"
        assert html_body.replace("\r\n", "\n") == util_load_text("test_data/expected_html_body.txt")


@pytest.mark.parametrize(
    "email_part, args",
    [
        (EmailParts.HEADERS, {"entryId": "XYZ", "additionalInstructions": "Identify spoofing."}),
        (EmailParts.BODY, {"entryId": "123", "additionalInstructions": "Identify data breaches."}),
    ],
)
def test_check_email_parts(mocker, email_part: str, args: dict):
    """Tests 'check_email_parts' function."""

    mocker.patch.object(OpenAiClient, "_http_request", return_value=util_load_json("test_data/mock_response.json"))
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"},
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-4", proxy=False, verify=False)
    check_email_part(email_part, client, args)


@pytest.mark.parametrize(
    "args, params",
    [
        (
            {"entry_id": "XYZ", "additional_instructions": "Pay close attention to SPF/DKIM."},
            {},
        ),
        (
            {"entry_id": "XYZ"},
            {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"},
        ),
        (
            {
                "entry_id": "XYZ",
                "max_tokens": "100",
                "temperature": "0.1",
                "top_p": "0.5",
                "reasoning_effort": "high",
            },
            {},
        ),
    ],
    ids=[
        "with-additional-instructions",
        "with-instance-params-fallback",
        "with-all-args-and-reasoning",
    ],
)
def test_analyze_email_header_command(mocker, args: dict, params: dict):
    """Tests 'analyze_email_header_command' using the Responses API."""
    mock_response = util_load_json("test_data/mock_responses_api_response.json")
    mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response)
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"},
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False)
    result = analyze_email_header_command(client, args, params)

    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert "SPF" in result.outputs[0]["assistant"]
    assert result.readable_output is not None
    assert "gpt-5" in result.readable_output


def test_analyze_email_header_command_no_headers(mocker):
    """Tests that analyze_email_header_command raises when no headers are found."""
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "./test_data/dummy_file.txt", "name": "dummy_file.eml"},
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False)
    with pytest.raises(Exception):
        analyze_email_header_command(client, {"entry_id": "XYZ"}, {})


@pytest.mark.parametrize(
    "args, params",
    [
        (
            {"entry_id": "XYZ", "additional_instructions": "Check for phishing links."},
            {},
        ),
        (
            {"entry_id": "XYZ"},
            {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"},
        ),
        (
            {
                "entry_id": "XYZ",
                "max_tokens": "100",
                "temperature": "0.1",
                "top_p": "0.5",
                "reasoning_effort": "high",
            },
            {},
        ),
    ],
    ids=[
        "with-additional-instructions",
        "with-instance-params-fallback",
        "with-all-args-and-reasoning",
    ],
)
def test_analyze_email_body_command(mocker, args: dict, params: dict):
    """Tests 'analyze_email_body_command' using the Responses API."""
    mock_response = util_load_json("test_data/mock_responses_api_response.json")
    mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response)
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"},
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False)
    result = analyze_email_body_command(client, args, params)

    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert result.raw_response == mock_response
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert result.outputs[0]["response_id"] == "resp_XXXX"
    assert result.readable_output is not None
    assert "gpt-5" in result.readable_output


def test_analyze_email_body_command_no_body(mocker):
    """Tests that analyze_email_body_command raises when no body is found."""
    # Mock an .eml file that has headers but no body
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "./test_data/dummy_file.txt", "name": "dummy_file.eml"},
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False)
    with pytest.raises(Exception):
        analyze_email_body_command(client, {"entry_id": "XYZ"}, {})


@pytest.mark.parametrize(
    "args, params",
    [
        (
            {"additional_instructions": "Notify the user the email was quarantined."},
            {},
        ),
        (
            {},
            {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"},
        ),
        (
            {
                "additional_instructions": "Include remediation steps.",
                "max_tokens": "100",
                "temperature": "0.1",
                "top_p": "0.5",
                "reasoning_effort": "high",
            },
            {},
        ),
    ],
    ids=[
        "with-additional-instructions",
        "with-instance-params-fallback",
        "with-all-args-and-reasoning",
    ],
)
def test_draft_soc_email_command(mocker, args: dict, params: dict):
    """Tests 'draft_soc_email_command' using the Responses API."""
    mock_response = util_load_json("test_data/mock_responses_api_response.json")
    mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response)

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False)
    result = draft_soc_email_command(client, args, params)

    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert result.raw_response == mock_response
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert result.outputs[0]["response_id"] == "resp_XXXX"
    assert result.readable_output is not None
    assert "gpt-5" in result.readable_output


def test_draft_soc_email_command_no_model(mocker):
    """Tests that draft_soc_email_command raises when no model is configured."""
    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="", proxy=False, verify=False)
    with pytest.raises(Exception, match="No model specified"):
        draft_soc_email_command(client, {}, {})


@pytest.mark.parametrize(
    "args",
    [
        {"reset_conversation_history": True, "message": "Hi There!", "max_tokens": "100", "temperature": "0.1", "top_p": "0.1"},
        {
            "reset_conversation_history": True,
            "message": "Hi There!",
        },
        {
            "reset_conversation_history": False,
            "message": "Hi There!",
        },
    ],
    ids=["test-send-message-with-params", "test-send-message-no-params", "test-send-message-no-reset"],
)
def test_send_message_command(mocker, args):
    mocker.patch.object(OpenAiClient, "_http_request", return_value=util_load_json("test_data/mock_response.json"))
    mocker.patch.object(
        demisto,
        "context",
        return_value={
            "OpenAiChatGPTV3": {"Conversation": [{"user": "Hi There!", "assistant": "Hello! How can I assist you today?"}]}
        },
    )

    client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-4", proxy=False, verify=False)
    result, _ = send_message_command(client, args)
    assert result.outputs_prefix == "OpenAiChatGPTV3.Conversation"


# endregion


# region Event Collector tests - shared helpers
def _make_client(**overrides):
    """Build an OpenAiClient with all keys populated for event-collector tests.

    Pass `admin_api_key=""` / `compliance_api_key=""` / `chatgpt_base_url=...`
    via `overrides` to exercise guard branches.
    """

    return OpenAiClient(
        url=overrides.get("url", "https://api.openai.com/"),
        api_key=overrides.get("api_key", "CHAT_KEY"),
        model=overrides.get("model", "gpt-4"),
        proxy=overrides.get("proxy", False),
        verify=overrides.get("verify", False),
        admin_api_key=overrides.get("admin_api_key", "ADMIN_KEY"),
        compliance_api_key=overrides.get("compliance_api_key", "COMPLIANCE_KEY"),
        chatgpt_base_url=overrides.get("chatgpt_base_url", "https://api.chatgpt.com"),
    )


# endregion


# region Event Collector tests - small pure helpers
@pytest.mark.parametrize(
    "event, expected",
    [
        pytest.param({"id": "abc"}, "abc", id="happy-id-key"),
        pytest.param({"log_id": "xyz"}, "xyz", id="happy-log_id-fallback"),
        pytest.param({"event_id": 7}, "7", id="happy-numeric-coerced-to-string"),
        pytest.param({"uuid": "u-1"}, "u-1", id="happy-uuid-fallback"),
        pytest.param({"id": "primary", "log_id": "secondary"}, "primary", id="precedence-id-over-log_id"),
        pytest.param({"unrelated": "v"}, None, id="bad-no-known-key"),
        pytest.param({}, None, id="bad-empty-dict"),
    ],
)
def test_event_id(event, expected):
    """`event_id` picks the first present key in (id, log_id, event_id, uuid)."""

    assert event_id(event) == expected


@pytest.mark.parametrize(
    "events, previous_ids, expected_ids",
    [
        pytest.param([{"id": "1"}, {"id": "2"}, {"id": "3"}], ["1", "3"], ["2"], id="happy-filters-known"),
        pytest.param([{"id": "1"}, {"id": "2"}], [], ["1", "2"], id="happy-no-previous-returns-all"),
        pytest.param([], ["1"], [], id="bad-empty-events-returns-empty"),
        pytest.param([{"id": "1"}, {"id": "1"}], ["1"], [], id="edge-all-events-filtered"),
    ],
)
def test_deduplicate_events(events, previous_ids, expected_ids):
    """`deduplicate_events` drops events whose id is in `previous_ids`."""

    result = deduplicate_events(events, previous_ids=previous_ids)
    assert [e["id"] for e in result] == expected_ids


@pytest.mark.parametrize(
    "event, expect_time",
    [
        pytest.param(
            {"id": "a", "effective_at": int(datetime(2099, 1, 1, tzinfo=UTC).timestamp())},
            "2099-01-01T00:00:00Z",
            id="happy-effective_at-mapped-to-_time",
        ),
        pytest.param({"id": "a"}, None, id="bad-missing-effective_at-no-_time"),
        pytest.param({"id": "a", "effective_at": "not-a-number"}, None, id="bad-non-numeric-effective_at-no-_time"),
    ],
)
def test_enrich_audit_event(event, expect_time):
    """Audit enrichment: strict `_time` from `effective_at` only.

    Audit events are routed to a dedicated dataset, so no `source_log_type` field is added
    (only Compliance events need it because they all share one dataset).
    """

    enrich_audit_event(event)
    # `source_log_type` is intentionally NOT set on audit events.
    assert "source_log_type" not in event
    if expect_time is None:
        assert "_time" not in event
    else:
        assert event["_time"] == expect_time


@pytest.mark.parametrize(
    "event, api_event_type, expect_time, expect_source_log_type",
    [
        pytest.param(
            {"id": "c", "timestamp": "2099-01-01T12:34:56Z"},
            "AUDIT_LOG",
            "2099-01-01T12:34:56Z",
            "compliance_audit_log",
            id="happy-audit_log-mapped",
        ),
        pytest.param(
            {"id": "c", "timestamp": "2099-01-02T08:00:00Z"},
            "APP_LOG",
            "2099-01-02T08:00:00Z",
            "app_log",
            id="happy-app_log-mapped",
        ),
        # `_time` must come strictly from `timestamp` - `end_time` must NOT be used as a fallback.
        pytest.param(
            {"id": "c", "end_time": "2099-01-02T08:00:00Z"},
            "APP_LOG",
            None,
            "app_log",
            id="bad-no-timestamp-no-_time-no-fallback-to-end_time",
        ),
    ],
)
def test_enrich_compliance_event(event, api_event_type, expect_time, expect_source_log_type):
    """Compliance enrichment: `_time` from `timestamp`, `source_log_type` from the API event-type mapping,
    and `workspace_id` carried through onto every event."""

    workspace_id = "FAKE_WORKSPACE_UUID"
    enrich_compliance_event(event, api_event_type, workspace_id)
    assert event["source_log_type"] == expect_source_log_type
    assert event["_event_type"] == api_event_type
    assert event["workspace_id"] == workspace_id
    if expect_time is None:
        assert "_time" not in event
    else:
        assert event["_time"] == expect_time


def test_enrich_compliance_event_unknown_event_type_logs_info_and_falls_back(mocker):
    """Unknown api_event_type passes through lowercased AND logs at info so maintainers can spot
    new OpenAI compliance event types without DEBUG-level scraping."""
    info_mock = mocker.patch.object(demisto, "info")
    event: dict[str, Any] = {"id": "FAKE_FUTURE_001", "timestamp": "2099-01-01T00:00:00Z"}

    enrich_compliance_event(event, api_event_type="FUTURE_NEW_TYPE", workspace_id="FAKE_WORKSPACE_UUID")

    assert event["source_log_type"] == "future_new_type"
    assert event["_event_type"] == "FUTURE_NEW_TYPE"
    assert event["workspace_id"] == "FAKE_WORKSPACE_UUID"
    assert info_mock.called, "Unknown api_event_type must log at info level."
    assert any("FUTURE_NEW_TYPE" in (c.args[0] if c.args else "") for c in info_mock.call_args_list)


@pytest.mark.parametrize(
    "first_fetch_input, expected_delta",
    [
        pytest.param("1 day", timedelta(days=1), id="happy-1-day"),
        pytest.param("3 days", timedelta(days=3), id="happy-3-days"),
        pytest.param("1 minute", timedelta(minutes=1), id="happy-1-minute"),
        pytest.param("1 minute ago", timedelta(minutes=1), id="happy-1-minute-ago-suffix"),
        pytest.param("7 days", timedelta(days=7), id="happy-7-days"),
        pytest.param("2 hours", timedelta(hours=2), id="happy-2-hours"),
        pytest.param("30 minutes", timedelta(minutes=30), id="happy-30-minutes"),
    ],
)
def test_parse_first_fetch_to_datetime_happy_path(mocker, first_fetch_input, expected_delta):
    """`parse_first_fetch_to_datetime` returns a timezone-aware UTC datetime for valid inputs.

    Covers relative time expressions across a range of magnitudes (minutes -> days) and both
    bare ("1 minute") and "-ago" suffix ("1 minute ago") forms.
    """
    error_mock = mocker.patch.object(demisto, "error")

    result = parse_first_fetch_to_datetime(first_fetch_input)
    assert isinstance(result, datetime)
    assert result.tzinfo is not None, "Returned datetime must be timezone-aware (UTC enforced)."

    expected = datetime.now(UTC) - expected_delta
    # Allow a small clock-drift window (test runtime + arg_to_datetime parse latency).
    assert abs((result - expected).total_seconds()) < 30
    assert not error_mock.called, "Valid first_fetch must NOT emit demisto.error."


@pytest.mark.parametrize(
    "bad_input",
    [
        pytest.param("not-a-real-time", id="garbage-string"),
        pytest.param("definitely-not-a-time", id="another-garbage-string"),
        pytest.param("", id="empty-string"),
        pytest.param("   ", id="whitespace-only"),
        pytest.param("1 banana", id="number-with-nonsense-unit"),
        pytest.param("yesterday-ish", id="ambiguous-typo"),
    ],
)
def test_parse_first_fetch_to_datetime_bad_input_falls_back_to_default(mocker, bad_input):
    """Unparseable input MUST fall back to `Config.DEFAULT_FIRST_FETCH`, never to a hardcoded window.

    This locks the regression where a typo silently widened the lookback window beyond
    the documented default. Whitespace, empty strings, and made-up unit names all must
    reach the fallback path.
    """
    # Suppress the demisto.error stdout under pytest; the error-log contract is asserted by
    # test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input.
    mocker.patch.object(demisto, "error")

    result = parse_first_fetch_to_datetime(bad_input)
    assert isinstance(result, datetime)
    assert result.tzinfo is not None, "Fallback must also be timezone-aware."

    # The fallback MUST equal Config.DEFAULT_FIRST_FETCH, never a hardcoded window.
    expected_fallback = arg_to_datetime(Config.DEFAULT_FIRST_FETCH, is_utc=True)
    assert expected_fallback is not None
    if expected_fallback.tzinfo is None:
        expected_fallback = expected_fallback.replace(tzinfo=UTC)
    drift = abs((result - expected_fallback).total_seconds())
    assert drift < 30, f"Fallback drifted {drift:.1f}s from Config.DEFAULT_FIRST_FETCH - the bug regressed."


def test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input(mocker):
    """Unparseable input MUST emit a `demisto.error` so operators see the misconfiguration
    in standard log queries (separate concern from the fallback value itself)."""
    error_mock = mocker.patch.object(demisto, "error")
    parse_first_fetch_to_datetime("1 banana")
    assert error_mock.called, "Unparseable first_fetch must log at error level."
    # The error message must reference the bad input and the documented fallback.
    error_args = error_mock.call_args[0][0]
    assert "1 banana" in error_args
    assert Config.DEFAULT_FIRST_FETCH in error_args


def test_parse_first_fetch_to_datetime_unix_seconds_format():
    """Audit-stream call site: `int(dt.timestamp())` must produce a valid Unix-seconds integer."""
    dt = parse_first_fetch_to_datetime("1 day")
    unix_seconds = int(dt.timestamp())
    assert isinstance(unix_seconds, int)
    expected = int((datetime.now(UTC) - timedelta(days=1)).timestamp())
    assert abs(unix_seconds - expected) < 30


def test_parse_first_fetch_to_datetime_iso_format():
    """Compliance-stream call site must produce a clean ISO 8601 string.

    Verifies that `.replace(microsecond=0).strftime(Config.DATE_FORMAT)` yields a
    valid wire-format timestamp ending in `Z`.
    """
    dt = parse_first_fetch_to_datetime("7 days")
    iso = dt.replace(microsecond=0).strftime(Config.DATE_FORMAT)
    assert isinstance(iso, str)
    assert iso.endswith("Z")
    parsed = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
    expected = datetime.now(UTC) - timedelta(days=7)
    assert abs((parsed - expected).total_seconds()) < 30


def test_selected_audit_enabled_and_compliance_event_types():
    """Selection helpers should classify user-facing labels into Audit vs Compliance buckets."""

    selected = [EventType.AUDIT, EventType.AUDIT_LOG, EventType.APP_LOG]
    assert selected_audit_enabled(selected) is True
    api_types = selected_compliance_event_types(selected)
    assert ComplianceEvent.AUDIT_LOG in api_types
    assert ComplianceEvent.APP_LOG in api_types

    # Negative: no Audit selected -> helper returns False; no Compliance -> empty list.
    assert selected_audit_enabled([EventType.APP_LOG]) is False
    assert selected_compliance_event_types([EventType.AUDIT]) == []


# endregion


# region Event Collector tests - integration params parsing
@pytest.mark.parametrize(
    "params, expected",
    [
        pytest.param(
            {
                "url": "https://api.openai.com",
                "apikey": {"password": "FAKE_CHAT_KEY"},
                "admin_api_key": {"password": "FAKE_ADMIN_KEY"},
                "compliance_api_key": {"password": "FAKE_COMPLIANCE_KEY"},
                "chatgpt_api_url": "https://fake-compliance.invalid",
                "model-freetext": "fake-model-x",
                "insecure": False,
                "proxy": False,
                "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"],
            },
            {
                "base_url": "https://api.openai.com/",
                "api_key": "FAKE_CHAT_KEY",
                "admin_api_key": "FAKE_ADMIN_KEY",
                "compliance_api_key": "FAKE_COMPLIANCE_KEY",
                "chatgpt_base_url": "https://fake-compliance.invalid",
                "model": "fake-model-x",
                "verify": True,
                "proxy": False,
            },
            id="happy-full-config-credentials-dict",
        ),
        pytest.param(
            {
                "apikey": "FAKE_RAW_STRING_KEY",  # Not wrapped in {"password": ...}
                "model-select": "fake-model-y",
                "insecure": True,
                "proxy": True,
                "event_types_to_fetch": [],
            },
            {
                "base_url": "https://api.openai.com/",  # default
                "api_key": "FAKE_RAW_STRING_KEY",
                "admin_api_key": "",
                "compliance_api_key": "",
                "chatgpt_base_url": "https://api.chatgpt.com",  # integration default
                "model": "fake-model-y",
                "verify": False,
                "proxy": True,
            },
            id="happy-defaults-and-raw-string-key",
        ),
    ],
)
def test_parse_integration_params_happy_paths(params, expected):
    """`parse_integration_params` extracts all fields and applies the documented defaults."""

    config = parse_integration_params(params)
    for key, value in expected.items():
        assert config[key] == value, f"Mismatch on '{key}': {config[key]!r} != {value!r}"


@pytest.mark.parametrize(
    "params, expected_substr",
    [
        pytest.param(
            {
                "event_types_to_fetch": ["NotAnEventType"],
            },
            "Invalid event type",
            id="bad-unknown-event-type",
        ),
        pytest.param(
            {
                "admin_api_key": {"password": ""},
                "event_types_to_fetch": ["OpenAI Audit logs"],
            },
            "Admin API Key",
            id="bad-audit-without-admin-key",
        ),
        pytest.param(
            {
                "compliance_api_key": {"password": ""},
                "event_types_to_fetch": ["Compliance Audit"],
            },
            "Compliance API Key",
            id="bad-compliance-without-compliance-key",
        ),
    ],
)
def test_parse_integration_params_bad_paths(params, expected_substr):
    """`parse_integration_params` raises informative `DemistoException` for invalid combos."""

    with pytest.raises(DemistoException) as exc_info:
        parse_integration_params(params)
    assert expected_substr in str(exc_info.value)


@pytest.mark.parametrize(
    "event_types, admin_key, compliance_key, expect_raises, expected_substr",
    [
        pytest.param([], "", "", False, None, id="happy-empty-selection-no-validation"),
        pytest.param(
            ["OpenAI Audit logs", "Compliance Audit"],
            "admin",
            "compliance",
            False,
            None,
            id="happy-both-keys-both-groups",
        ),
        pytest.param(["OpenAI Audit logs"], "", "any", True, "Admin API Key", id="bad-audit-missing-admin-key"),
        pytest.param(
            ["Compliance Audit", "Apps"],
            "any",
            "",
            True,
            "Compliance API Key",
            id="bad-compliance-missing-compliance-key",
        ),
    ],
)
def test_validate_event_types_credentials_correlation(event_types, admin_key, compliance_key, expect_raises, expected_substr):
    """Cross-validate selected event types vs. provided credentials."""

    if expect_raises:
        with pytest.raises(DemistoException) as exc_info:
            validate_event_types_credentials_correlation(
                event_types_to_fetch=event_types,
                admin_api_key=admin_key,
                compliance_api_key=compliance_key,
            )
        assert expected_substr in str(exc_info.value)
    else:
        # Should not raise.
        validate_event_types_credentials_correlation(
            event_types_to_fetch=event_types,
            admin_api_key=admin_key,
            compliance_api_key=compliance_key,
        )


# endregion


# region Event Collector tests - parse_concatenated_json
@pytest.mark.parametrize(
    "body, expected",
    [
        pytest.param(
            '{"a":1,"nested":{"x":2}}{"b":2}\n{"c":3}',
            [{"a": 1, "nested": {"x": 2}}, {"b": 2}, {"c": 3}],
            id="happy-concatenated-objects",
        ),
        pytest.param(
            '{"a":1}\n  {"b":2}\n\n{"c":3}\n',
            [{"a": 1}, {"b": 2}, {"c": 3}],
            id="happy-jsonl-with-whitespace",
        ),
        pytest.param(
            '{"a":1}"ignored"42[1,2,3]{"b":2}',
            [{"a": 1}, {"b": 2}],
            id="happy-non-dict-top-level-values-skipped",
        ),
        pytest.param("", [], id="bad-empty-body-returns-empty-list"),
        pytest.param("   \n  ", [], id="bad-whitespace-only-returns-empty-list"),
        pytest.param('{"a":1}garbage', [{"a": 1}], id="bad-trailing-garbage-stops-parser-keeps-decoded"),
    ],
)
def test_parse_concatenated_json(body, expected, capfd):
    """`parse_concatenated_json` splits a stream of concatenated JSON / JSONL into a list of dicts."""

    # The "trailing garbage" case calls `demisto.error(...)` which writes to stdout in the test runtime.
    with capfd.disabled():
        assert parse_concatenated_json(body) == expected


def test_parse_concatenated_json_loads_fixture_file():
    """End-to-end check using a synthetic concatenated-JSON body stored under test_data/."""

    body = util_load_text("test_data/compliance_log_content_concatenated.txt")
    records = parse_concatenated_json(body)
    assert len(records) == 3
    assert records[0]["actor"] == "FAKE_ACTOR_A"
    assert records[-1]["action"] == "dummy_action_three"


# endregion


# region Event Collector tests - Client guards & wire format
@pytest.mark.parametrize(
    "client_kwargs, call_kwargs, expected_substr",
    [
        pytest.param(
            {"admin_api_key": ""},
            {},
            "Admin API Key",
            id="bad-audit-without-admin-key",
        ),
    ],
)
def test_get_audit_logs_guards(client_kwargs, call_kwargs, expected_substr):
    """`Client.get_audit_logs` must refuse to fire without an Admin API key."""
    client = _make_client(**client_kwargs)
    with pytest.raises(DemistoException) as exc_info:
        client.get_audit_logs(**call_kwargs)
    assert expected_substr in str(exc_info.value)


def test_get_audit_logs_uses_cursor_when_present(mocker):
    """Happy path: when `after=` is provided, the request must NOT include `effective_at[gt]`."""

    client = _make_client()
    response = util_load_json("test_data/audit_logs_page_response.json")
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=response)

    result = client.get_audit_logs(after="FAKE_AUDIT_CURSOR_PREV", effective_at_gt=1000)
    assert result["last_id"] == "FAKE_AUDIT_CURSOR_AAAA"

    request_params = http_mock.call_args.kwargs["params"]
    assert request_params["after"] == "FAKE_AUDIT_CURSOR_PREV"
    # When a cursor is present, the time-seed must be ignored (cursor wins).
    assert "effective_at[gt]" not in request_params


def test_get_audit_logs_uses_time_seed_on_first_call(mocker):
    """First-ever call (no cursor) must seed the request with `effective_at[gt]`."""

    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False})

    client.get_audit_logs(after=None, effective_at_gt=1234567890)

    request_params = http_mock.call_args.kwargs["params"]
    assert request_params["effective_at[gt]"] == 1234567890
    assert "after" not in request_params


@pytest.mark.parametrize(
    "client_kwargs, call_kwargs, expected_substr",
    [
        pytest.param(
            {"compliance_api_key": ""},
            {"workspace_id": "FAKE_WORKSPACE_ID", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"},
            "Compliance API Key",
            id="bad-no-compliance-key",
        ),
        pytest.param(
            {"compliance_api_key": "FAKE_COMPLIANCE_KEY"},
            {"workspace_id": "", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"},
            "Workspace ID",
            id="bad-no-workspace-id",
        ),
    ],
)
def test_list_compliance_logs_guards(client_kwargs, call_kwargs, expected_substr):
    """`Client.list_compliance_logs` must refuse to fire without both a key and a workspace."""
    client = _make_client(**client_kwargs)
    with pytest.raises(DemistoException) as exc_info:
        client.list_compliance_logs(**call_kwargs)
    assert expected_substr in str(exc_info.value)


@pytest.mark.parametrize(
    "upstream, expected_data, expected_last_end_time",
    [
        pytest.param(
            {
                "data": [{"id": "FAKE_LISTING_001", "end_time": "2099-01-02T00:00:00Z"}],
                "has_more": False,
                "last_end_time": "2099-01-02T00:00:00Z",
            },
            [{"id": "FAKE_LISTING_001", "end_time": "2099-01-02T00:00:00Z"}],
            "2099-01-02T00:00:00Z",
            id="happy-dict-shape-with-last_end_time",
        ),
        pytest.param(
            [{"id": "FAKE_LISTING_002", "end_time": "2099-02-01T00:00:00Z"}],
            [{"id": "FAKE_LISTING_002", "end_time": "2099-02-01T00:00:00Z"}],
            None,
            id="happy-legacy-bare-list-shape-normalized",
        ),
        pytest.param(None, [], None, id="bad-non-list-non-dict-response-normalized-empty"),
        pytest.param({}, [], None, id="bad-empty-dict-response-normalized-empty"),
    ],
)
def test_list_compliance_logs_normalizes_response(mocker, upstream, expected_data, expected_last_end_time):
    """`list_compliance_logs` must normalize any upstream shape into `{data, last_end_time}`."""

    client = _make_client()
    mocker.patch.object(OpenAiClient, "_http_request", return_value=upstream)
    result = client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["APP_LOG"], after="2099-01-01T00:00:00Z")
    assert result["data"] == expected_data
    assert result.get("last_end_time") == expected_last_end_time


def test_get_compliance_log_content_parses_concatenated_json(mocker):
    """`get_compliance_log_content` fetches as text and parses concatenated JSON into a list of dicts."""

    client = _make_client()
    body = util_load_text("test_data/compliance_log_content_concatenated.txt")
    mocker.patch.object(OpenAiClient, "_http_request", return_value=body)

    records = client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LISTING_002")
    assert len(records) == 3
    assert all(isinstance(r, dict) for r in records)
    assert records[0]["action"] == "dummy_action_one"


def test_get_compliance_log_content_requires_compliance_key():
    """Bad path: missing Compliance API Key must raise before any HTTP request."""
    client = _make_client(compliance_api_key="")
    with pytest.raises(DemistoException) as exc_info:
        client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LISTING_002")
    assert "Compliance API Key" in str(exc_info.value)


# region Retry policy tests
# =============================================================================
# Verify that event-collector HTTP calls forward `Config.RETRY_POLICY` to
# `_http_request`, while chat-completion stays fail-fast (no retry). Without
# this contract, transient OpenAI 5xx/429 errors silently fail the fetch and
# the UI shows an opaque "Error pulling at <time>" with no actionable details.
# =============================================================================


def test_retry_policy_values_are_sensible():
    """The retry policy constant must use the expected values - any drift here is a behaviour change
    operators rely on (number of retries, which statuses retry, exponential backoff factor)."""
    policy = module.Config.RETRY_POLICY
    assert policy["retries"] == 3
    assert set(policy["status_list_to_retry"]) == {429, 500, 502, 503, 504}
    assert policy["backoff_factor"] == 2
    # `raise_on_status=True` is what converts an exhausted retry into a `DemistoException`
    # rather than silently returning the last error response body.
    assert policy["raise_on_status"] is True


@pytest.mark.parametrize(
    "method_name, call_kwargs",
    [
        pytest.param(
            "get_audit_logs",
            {"after": "FAKE_AUDIT_CURSOR", "effective_at_gt": None},
            id="happy-audit-logs-forwards-retry-policy",
        ),
        pytest.param(
            "list_compliance_logs",
            {"workspace_id": "FAKE_WORKSPACE_ID", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"},
            id="happy-compliance-list-forwards-retry-policy",
        ),
        pytest.param(
            "get_compliance_log_content",
            {"workspace_id": "FAKE_WORKSPACE_ID", "log_id": "FAKE_LOG_001"},
            id="happy-compliance-content-forwards-retry-policy",
        ),
    ],
)
def test_event_collector_calls_forward_retry_policy(mocker, method_name, call_kwargs):
    """Good path: every event-collector `_http_request` call must include the RETRY_POLICY kwargs.

    This is asserted at the contract layer (the kwargs forwarded into `_http_request`) rather than
    by simulating a 5xx and counting retries, which would couple the test to `urllib3.Retry`'s
    internals and break across CI image upgrades.
    """
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}")

    getattr(client, method_name)(**call_kwargs)

    forwarded = http_mock.call_args.kwargs
    for key, expected in module.Config.RETRY_POLICY.items():
        assert forwarded.get(key) == expected, f"{method_name}: missing/wrong '{key}' kwarg passed to _http_request"


def test_chat_completions_does_not_forward_retry_policy(mocker):
    """Bad path: chat-completion is interactive and intentionally NOT retried so the user sees
    transient errors immediately in the war room rather than waiting through backoffs."""
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"choices": [{"message": {"content": "hi"}}]})

    client.get_chat_completions(chat_context=[{"role": "user", "content": "ping"}], completion_params={})

    forwarded = http_mock.call_args.kwargs
    for key in module.Config.RETRY_POLICY:
        assert key not in forwarded, f"chat-completions must NOT forward '{key}' (interactive commands must fail fast)"


EXPECTED_NEW_API_RETRY_POLICY = {
    "retries": 3,
    "status_list_to_retry": [429, 500, 502, 503, 504],
    "backoff_factor": 5,
}


@pytest.mark.parametrize(
    "method_name, call_kwargs",
    [
        pytest.param(
            "list_models",
            {},
            id="list-models-forwards-retry-policy",
        ),
        pytest.param(
            "create_moderation",
            {"body": {"model": "omni-moderation-latest", "input": [{"type": "text", "text": "test"}]}},
            id="create-moderation-forwards-retry-policy",
        ),
        pytest.param(
            "create_response",
            {"body": {"model": "gpt-4", "input": "test"}},
            id="create-response-forwards-retry-policy",
        ),
        pytest.param(
            "get_response",
            {"response_id": "resp_FAKE_001"},
            id="get-response-forwards-retry-policy",
        ),
    ],
)
def test_new_api_calls_forward_retry_policy(mocker, method_name, call_kwargs):
    """Every new API method (Models, Moderations, Responses) must include retry kwargs
    with retries=3, status_list_to_retry=[429, 500, 502, 503, 504], backoff_factor=5.

    Asserted at the contract layer (kwargs forwarded into ``_http_request``) rather than
    by simulating a 5xx and counting retries.
    """
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}")

    getattr(client, method_name)(**call_kwargs)

    forwarded = http_mock.call_args.kwargs
    for key, expected in EXPECTED_NEW_API_RETRY_POLICY.items():
        assert forwarded.get(key) == expected, (
            f"{method_name}: missing/wrong '{key}' kwarg passed to _http_request "
            f"(expected={expected!r}, got={forwarded.get(key)!r})"
        )


# endregion


def test_send_events_routes_to_correct_dataset(mocker):
    """`send_events` must call `send_events_to_xsiam` with the requested vendor + product."""

    client = _make_client()
    sender = mocker.patch.object(module, "send_events_to_xsiam")

    events = [{"id": "e1"}]
    client.send_events(events, product=module.Config.PRODUCT_AUDIT)

    sender.assert_called_once_with(events=events, vendor=module.Config.VENDOR, product=module.Config.PRODUCT_AUDIT)


def test_send_events_skips_when_empty(mocker):
    """`send_events` must NOT call the XSIAM sender when there are no events."""

    client = _make_client()
    sender = mocker.patch.object(module, "send_events_to_xsiam")

    client.send_events([], product=module.Config.PRODUCT_COMPLIANCE)
    sender.assert_not_called()


# endregion


# region Event Collector tests - fetch_audit_logs
def test_fetch_audit_logs_returns_events_and_advances_cursor(mocker):
    """Happy path: a single page is fetched, events enriched, and the API `last_id` cursor persisted."""

    client = _make_client()
    response = util_load_json("test_data/audit_logs_page_response.json")
    mocker.patch.object(OpenAiClient, "_http_request", return_value=response)

    events, updates = fetch_audit_logs(client=client, last_run={}, max_fetch=10, first_fetch="1 day")

    assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_001", "FAKE_AUDIT_EVENT_002"]
    # Audit events are routed to a dedicated dataset; `source_log_type` is intentionally NOT set.
    assert all("source_log_type" not in e for e in events)
    # The cursor returned by the API is what gets persisted - verbatim.
    assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA"
    # Audit no longer keeps an explicit ID-list / time HWM in last_run.
    assert "audit_effective_at" not in updates
    assert "audit_last_ids" not in updates


def test_fetch_audit_logs_resumes_from_stored_cursor(mocker):
    """Happy path: stored cursor is forwarded as `after=`; first-fetch time-seed is NOT used."""

    client = _make_client()
    response = {
        "data": [{"id": "FAKE_AUDIT_EVENT_003", "effective_at": 1200}],
        "has_more": False,
        "last_id": "FAKE_AUDIT_CURSOR_BBBB",
    }
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=response)

    last_run = {LastRunKey.AUDIT_AFTER: "FAKE_AUDIT_CURSOR_AAAA"}
    events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 day")

    assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_003"]
    assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_BBBB"

    request_params = http_mock.call_args.kwargs.get("params", {})
    assert request_params.get("after") == "FAKE_AUDIT_CURSOR_AAAA"
    assert "effective_at[gt]" not in request_params


def test_fetch_audit_logs_empty_first_fetch_persists_seed(mocker):
    """Bad path: first-ever fetch returns empty - no events, but the time-seed is persisted so
    the next cycle replays the same lookback window instead of sliding it with `now()`."""

    client = _make_client()
    mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False})
    mocker.patch(
        "OpenAiChatGPTV3.parse_first_fetch_to_datetime",
        return_value=datetime.fromtimestamp(1700000000, tz=UTC),
    )
    debug_mock = mocker.patch.object(demisto, "debug")

    events, updates = fetch_audit_logs(client=client, last_run={}, max_fetch=10, first_fetch="1 minute ago")

    assert events == []
    # The seed MUST be persisted; the cursor key MUST NOT appear.
    assert updates == {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000}
    assert LastRunKey.AUDIT_AFTER not in updates
    # Operators must see a clear log line so first-empty-fetch is discoverable in standard log queries.
    assert any("persisting seed=1700000000" in (c.args[0] if c.args else "") for c in debug_mock.call_args_list)


def test_fetch_audit_logs_replays_stored_seed_without_recomputing(mocker):
    """The stored seed is replayed verbatim across empty cycles; `parse_first_fetch_*` is not called again."""

    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False})
    parse_mock = mocker.patch("OpenAiChatGPTV3.parse_first_fetch_to_datetime")
    debug_mock = mocker.patch.object(demisto, "debug")

    last_run = {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000}
    events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 minute ago")

    assert events == []
    # Seed already present - do NOT recompute, do NOT re-persist (keeps last_run idempotent).
    parse_mock.assert_not_called()
    assert updates == {}
    # The same seed was forwarded as `effective_at[gt]` on the wire.
    request_params = http_mock.call_args.kwargs.get("params", {})
    assert request_params.get("effective_at[gt]") == 1700000000
    # A "replaying ... seed" debug line must be emitted so operators can correlate sustained empty fetches.
    assert any("Replaying persisted first-fetch seed" in (c.args[0] if c.args else "") for c in debug_mock.call_args_list)


def test_fetch_audit_logs_real_cursor_clears_stored_seed(mocker):
    """Once a real `last_id` arrives, the seed is cleared so it never reappears in future runs."""

    client = _make_client()
    response = {
        "data": [{"id": "FAKE_AUDIT_EVENT_001", "effective_at": 1700000001}],
        "has_more": False,
        "last_id": "FAKE_AUDIT_CURSOR_AAAA",
    }
    mocker.patch.object(OpenAiClient, "_http_request", return_value=response)

    last_run = {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000}
    events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 minute ago")

    assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_001"]
    assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA"
    # Seed must be explicitly cleared (None) so it is dropped from `last_run` on merge.
    assert updates[LastRunKey.AUDIT_FIRST_FETCH_SEED] is None


# endregion


# region Event Collector tests - fetch_compliance_logs
def test_fetch_compliance_logs_two_step_flow(mocker):
    """Happy path: list endpoint -> per-id content fetch -> per-record enrichment + cursor persistence."""

    client = _make_client()
    listing_response = util_load_json("test_data/compliance_listing_response.json")
    contents = {
        "FAKE_LISTING_001": [{"id": "FAKE_LISTING_001", "timestamp": "2099-01-01T00:00:00Z", "actor": "FAKE_ACTOR_A"}],
        "FAKE_LISTING_002": [
            {"id": "FAKE_LISTING_002", "timestamp": "2099-01-02T00:00:00Z", "actor": "FAKE_ACTOR_B"},
            {"id": "FAKE_LISTING_002", "timestamp": "2099-01-02T00:00:01Z", "actor": "FAKE_ACTOR_C"},
        ],
    }
    mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response)
    mocker.patch.object(OpenAiClient, "get_compliance_log_content", side_effect=lambda workspace_id, log_id: contents[log_id])

    events, updates = fetch_compliance_logs(
        client=client,
        workspace_id="FAKE_WORKSPACE_ID",
        api_event_types=[ComplianceEvent.AUDIT_LOG, ComplianceEvent.APP_LOG],
        last_run={},
        max_fetch=100,
        first_fetch="1 day",
    )

    # FAKE_LISTING_001 -> 1 record, FAKE_LISTING_002 -> 2 records (JSONL-shaped content) = 3 events total.
    assert len(events) == 3
    assert events[0]["source_log_type"] == SourceLogType.COMPLIANCE_AUDIT_LOG
    assert events[1]["source_log_type"] == SourceLogType.APP_LOG
    # `workspace_id` is propagated from the fetch call into every enriched event.
    assert all(e["workspace_id"] == "FAKE_WORKSPACE_ID" for e in events)
    # `last_end_time` is read from the listing response, NOT computed from individual entries.
    assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-01-02T00:00:00Z"
    # Only FAKE_LISTING_002 shares the end_time with the cursor, so it's the only id stored for tie-dedup.
    assert updates[LastRunKey.COMPLIANCE_LAST_IDS] == ["FAKE_LISTING_002"]


def test_fetch_compliance_logs_dedupes_against_previous_ids(mocker):
    """Happy path: listings whose IDs were already seen at the persisted `last_end_time` are skipped."""

    client = _make_client()
    listings = [
        {"id": "FAKE_LISTING_001", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"},
        {"id": "FAKE_LISTING_002", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"},
    ]
    listing_response = {"data": listings, "last_end_time": "2099-01-02T00:00:00Z", "has_more": False}
    mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response)
    mocker.patch.object(
        OpenAiClient,
        "get_compliance_log_content",
        side_effect=lambda workspace_id, log_id: [{"id": log_id, "timestamp": "2099-01-02T00:00:00Z"}],
    )

    last_run = {
        LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-02T00:00:00Z",
        LastRunKey.COMPLIANCE_LAST_IDS: ["FAKE_LISTING_001"],
    }
    events, updates = fetch_compliance_logs(
        client=client,
        workspace_id="FAKE_WORKSPACE_ID",
        api_event_types=[ComplianceEvent.AUDIT_LOG],
        last_run=last_run,
        max_fetch=100,
        first_fetch="1 day",
    )

    assert [e["id"] for e in events] == ["FAKE_LISTING_002"]
    assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-01-02T00:00:00Z"
    # Same cursor as previous run - merge stored IDs with newly-seen IDs at the same timestamp.
    assert sorted(updates[LastRunKey.COMPLIANCE_LAST_IDS]) == ["FAKE_LISTING_001", "FAKE_LISTING_002"]


def test_fetch_compliance_logs_no_listings_advances_cursor_only(mocker):
    """Bad path: no listings returned, but the API's `last_end_time` still moves forward -> persist it."""

    client = _make_client()
    mocker.patch.object(
        OpenAiClient,
        "list_compliance_logs",
        return_value={"data": [], "last_end_time": "2099-02-01T00:00:00Z", "has_more": False},
    )

    last_run = {LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-01T00:00:00Z"}
    events, updates = fetch_compliance_logs(
        client=client,
        workspace_id="FAKE_WORKSPACE_ID",
        api_event_types=[ComplianceEvent.AUDIT_LOG],
        last_run=last_run,
        max_fetch=100,
        first_fetch="1 day",
    )
    assert events == []
    assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-02-01T00:00:00Z"
    assert updates[LastRunKey.COMPLIANCE_LAST_IDS] == []


def test_fetch_compliance_logs_content_failure_isolated(mocker, capfd):
    """Bad path: a single content-fetch failure must NOT abort processing of other listings."""

    client = _make_client()
    listings = [
        {"id": "FAKE_LISTING_001", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"},
        {"id": "FAKE_LISTING_002", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:01Z"},
    ]
    listing_response = {"data": listings, "last_end_time": "2099-01-02T00:00:01Z", "has_more": False}

    def content_side_effect(workspace_id, log_id):
        if log_id == "FAKE_LISTING_001":
            raise DemistoException("simulated content fetch failure")
        return [{"id": log_id, "timestamp": "2099-01-02T00:00:01Z"}]

    mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response)
    mocker.patch.object(OpenAiClient, "get_compliance_log_content", side_effect=content_side_effect)

    # `fetch_compliance_logs` calls `demisto.error(...)` when one or more content fetches fail (writes to stdout).
    with capfd.disabled():
        events, _ = fetch_compliance_logs(
            client=client,
            workspace_id="FAKE_WORKSPACE_ID",
            api_event_types=[ComplianceEvent.AUDIT_LOG],
            last_run={},
            max_fetch=100,
            first_fetch="1 day",
        )
    # FAKE_LISTING_001 failed and was skipped; FAKE_LISTING_002 succeeded and produced one event.
    assert [e["id"] for e in events] == ["FAKE_LISTING_002"]


# endregion


# region Event Collector tests - command-level (fetch-events / openai-get-events)
def test_fetch_events_command_runs_streams_in_parallel_and_routes_datasets(mocker):
    """`fetch_events_command` runs both streams, sends each to its own dataset, persists merged last_run."""

    client = _make_client()
    audit_events = [{"id": "FAKE_AUDIT_EVENT_001"}]
    compliance_events = [{"id": "FAKE_COMPLIANCE_EVENT_001"}, {"id": "FAKE_COMPLIANCE_EVENT_002"}]

    mocker.patch.object(
        module, "fetch_audit_logs", return_value=(audit_events, {module.LastRunKey.AUDIT_AFTER: "FAKE_AUDIT_CURSOR_AAAA"})
    )
    mocker.patch.object(
        module,
        "fetch_compliance_logs",
        return_value=(compliance_events, {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-02-01T00:00:00Z"}),
    )
    sender = mocker.patch.object(module.OpenAiClient, "send_events")
    mocker.patch.object(demisto, "getLastRun", return_value={})
    set_last_run = mocker.patch.object(demisto, "setLastRun")

    params = {
        "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"],
        "workspace_id": "FAKE_WORKSPACE_ID",
        "first_fetch": "1 day",
        "audit_max_fetch": 100,
        "compliance_max_fetch": 50,
    }
    module.fetch_events_command(client=client, params=params)

    # Both streams pushed - each with its own product.
    products_used = sorted(
        call.kwargs.get("product", call.args[1] if len(call.args) > 1 else None) for call in sender.call_args_list
    )
    assert products_used == sorted([module.Config.PRODUCT_AUDIT, module.Config.PRODUCT_COMPLIANCE])

    # Last_run is the merged set of per-stream updates.
    persisted = set_last_run.call_args.args[0]
    assert persisted[module.LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA"
    assert persisted[module.LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-02-01T00:00:00Z"


def test_fetch_events_command_failure_in_one_stream_pushes_survivor_then_reraises(mocker, capfd):
    """`fetch_events_command` must:
    1. Let the surviving (compliance) stream push its events and persist its cursor.
    2. THEN re-raise the failed (audit) stream's exception so the UI shows a real error
       instead of an opaque "Error pulling at <time>" banner.
    """

    client = _make_client()

    mocker.patch.object(module, "fetch_audit_logs", side_effect=DemistoException("simulated audit failure"))
    mocker.patch.object(
        module,
        "fetch_compliance_logs",
        return_value=(
            [{"id": "FAKE_COMPLIANCE_EVENT_001"}],
            {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-02-02T00:00:00Z"},
        ),
    )
    sender = mocker.patch.object(module.OpenAiClient, "send_events")
    mocker.patch.object(demisto, "getLastRun", return_value={})
    set_last_run = mocker.patch.object(demisto, "setLastRun")

    params = {
        "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"],
        "workspace_id": "FAKE_WORKSPACE_ID",
        "first_fetch": "1 day",
    }
    with capfd.disabled(), pytest.raises(DemistoException) as exc_info:
        module.fetch_events_command(client=client, params=params)

    # The re-raised error must name the failing stream so the UI banner is actionable.
    assert "audit" in str(exc_info.value)
    assert "simulated audit failure" in str(exc_info.value)

    # Partial progress is preserved: compliance events were pushed AND its cursor was persisted
    # BEFORE the re-raise (otherwise the next run would re-fetch the same compliance events).
    pushed_products = [call.kwargs.get("product", call.args[1] if len(call.args) > 1 else None) for call in sender.call_args_list]
    assert module.Config.PRODUCT_COMPLIANCE in pushed_products
    assert module.Config.PRODUCT_AUDIT not in pushed_products
    assert set_last_run.called
    persisted = set_last_run.call_args.args[0]
    assert persisted.get(module.LastRunKey.COMPLIANCE_LAST_END_TIME) == "2099-02-02T00:00:00Z"


def test_fetch_events_command_no_event_types_selected_is_a_noop(mocker):
    """Bad path: if no event types are selected, neither stream runs and last_run is left untouched."""

    client = _make_client()
    audit_mock = mocker.patch.object(module, "fetch_audit_logs")
    compliance_mock = mocker.patch.object(module, "fetch_compliance_logs")
    sender = mocker.patch.object(module.OpenAiClient, "send_events")
    mocker.patch.object(demisto, "getLastRun", return_value={"unrelated": "preserved"})
    set_last_run = mocker.patch.object(demisto, "setLastRun")

    module.fetch_events_command(client=client, params={"event_types_to_fetch": []})
    audit_mock.assert_not_called()
    compliance_mock.assert_not_called()
    sender.assert_not_called()
    # Still calls setLastRun once with the original dict (preserves unrelated keys).
    assert set_last_run.call_args.args[0] == {"unrelated": "preserved"}


@pytest.mark.parametrize(
    "should_push, expected_send_calls",
    [
        pytest.param(False, 0, id="happy-no-push-by-default"),
        pytest.param(True, 2, id="happy-push-flag-pushes-both-datasets"),
    ],
)
def test_get_events_command(mocker, should_push, expected_send_calls):
    """`openai-get-events` returns events as `CommandResults`; only pushes when `should_push_events=true`."""

    client = _make_client()
    mocker.patch.object(module, "fetch_audit_logs", return_value=([{"id": "FAKE_AUDIT_EVENT_001"}], {}))
    mocker.patch.object(module, "fetch_compliance_logs", return_value=([{"id": "FAKE_COMPLIANCE_EVENT_001"}], {}))
    sender = mocker.patch.object(module.OpenAiClient, "send_events")

    args = {
        "event_type": "OpenAI Audit logs,Compliance Audit",
        "limit": "10",
        "should_push_events": should_push,
    }
    params = {"workspace_id": "FAKE_WORKSPACE_ID"}
    result = module.get_events_command(client=client, args=args, params=params)

    # `outputs` always carries every fetched event regardless of the push flag.
    outputs = cast(list, result.outputs)
    assert {e["id"] for e in outputs} == {"FAKE_AUDIT_EVENT_001", "FAKE_COMPLIANCE_EVENT_001"}
    assert sender.call_count == expected_send_calls


def test_get_events_command_failure_in_one_stream_does_not_block_the_other(mocker, capfd):
    """`openai-get-events` must isolate per-stream failures so one stream's exception cannot abort
    the entire command. Mirrors `fetch_events_command`'s isolation contract: the failing stream is
    logged via `demisto.error` and skipped, while the surviving stream's events are returned.
    """

    client = _make_client()
    # Audit stream blows up; compliance stream returns events successfully.
    mocker.patch.object(module, "fetch_audit_logs", side_effect=DemistoException("simulated audit failure"))
    mocker.patch.object(module, "fetch_compliance_logs", return_value=([{"id": "FAKE_COMPLIANCE_EVENT_001"}], {}))

    args = {
        "event_type": "OpenAI Audit logs,Compliance Audit",
        "limit": "10",
        "should_push_events": False,
    }
    params = {"workspace_id": "FAKE_WORKSPACE_ID"}

    with capfd.disabled():
        result = module.get_events_command(client=client, args=args, params=params)

    # The surviving (compliance) stream's events are returned; the failed (audit) stream is dropped.
    outputs = cast(list, result.outputs)
    assert {e["id"] for e in outputs} == {"FAKE_COMPLIANCE_EVENT_001"}


# endregion


# region Event Collector tests - new helpers (refactor coverage)
@pytest.mark.parametrize(
    "raw, expected",
    [
        pytest.param({"password": "FAKE_KEY"}, "FAKE_KEY", id="happy-dict-shape-type-9"),
        pytest.param({"identifier": "user", "password": "FAKE_KEY"}, "FAKE_KEY", id="happy-dict-shape-with-identifier"),
        pytest.param("FAKE_KEY", "FAKE_KEY", id="happy-plain-string-type-14"),
        pytest.param(None, "", id="happy-none-yields-empty"),
        pytest.param({}, "", id="happy-empty-dict-yields-empty"),
        pytest.param({"password": ""}, "", id="happy-dict-with-empty-password"),
        pytest.param({"password": None}, "", id="happy-dict-with-none-password"),
    ],
)
def test_extract_credential(raw, expected):
    """`_extract_credential` accepts both dict-shape (legacy type:9) and string-shape (modern type:14)."""

    assert _extract_credential(raw) == expected


@pytest.mark.parametrize(
    "raw, expected",
    [
        pytest.param(None, [], id="happy-none-yields-empty-list"),
        pytest.param([], [], id="happy-empty-list-yields-empty-list"),
        pytest.param(["OpenAI Audit logs"], ["OpenAI Audit logs"], id="happy-single-audit-label"),
        pytest.param(
            ["OpenAI Audit logs", "Compliance Audit"],
            ["OpenAI Audit logs", "Compliance Audit"],
            id="happy-mixed-audit-and-compliance",
        ),
        pytest.param(
            "OpenAI Audit logs,Compliance Audit",
            ["OpenAI Audit logs", "Compliance Audit"],
            id="happy-csv-string",
        ),
    ],
)
def test_parse_event_types_to_fetch_happy_paths(raw, expected):
    """`parse_event_types_to_fetch` normalizes various inputs into the canonical list."""

    assert parse_event_types_to_fetch(raw) == expected


def test_parse_event_types_to_fetch_rejects_unknown_labels():
    """Bad path: unknown labels surface as an informative `DemistoException`."""

    with pytest.raises(DemistoException) as exc_info:
        parse_event_types_to_fetch(["NotAnEventType"])
    assert "Invalid event type" in str(exc_info.value)


@pytest.mark.parametrize(
    "audit_selected, api_event_types, expected_streams",
    [
        pytest.param(False, [], [], id="none-selected-no-streams"),
        pytest.param(True, [], ["audit"], id="audit-only"),
        pytest.param(False, ["AUDIT_LOG"], ["compliance"], id="compliance-only"),
        pytest.param(True, ["AUDIT_LOG"], ["audit", "compliance"], id="both-streams-ordered-audit-first"),
    ],
)
def test_collector_params_streams_to_run(audit_selected, api_event_types, expected_streams):
    """`CollectorParams.streams_to_run` selects audit + compliance based on flags."""

    params = CollectorParams(
        event_types_to_fetch=[],
        audit_selected=audit_selected,
        api_event_types=api_event_types,
        audit_max_fetch=10,
        compliance_max_fetch=10,
        workspace_id="",
        first_fetch="1 day",
    )
    assert params.streams_to_run() == expected_streams
    assert params.compliance_selected is bool(api_event_types)


def test_parse_collector_params_no_args_uses_integration_defaults():
    """Happy path: when called from fetch-events (no args), values come from integration params."""

    params = {
        "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"],
        "audit_max_fetch": "200",
        "compliance_max_fetch": "150",
        "workspace_id": "FAKE_WORKSPACE_ID",
        "first_fetch": "3 days",
    }
    collector = parse_collector_params(params)

    assert collector.event_types_to_fetch == ["OpenAI Audit logs", "Compliance Audit"]
    assert collector.audit_selected is True
    assert collector.api_event_types == ["AUDIT_LOG"]
    assert collector.audit_max_fetch == 200
    assert collector.compliance_max_fetch == 150
    assert collector.workspace_id == "FAKE_WORKSPACE_ID"

    assert collector.first_fetch == Config.DEFAULT_FIRST_FETCH
    minimal = parse_collector_params({"event_types_to_fetch": []})
    assert minimal.audit_max_fetch == Config.DEFAULT_AUDIT_MAX_FETCH
    assert minimal.compliance_max_fetch == Config.DEFAULT_COMPLIANCE_MAX_FETCH
    assert minimal.first_fetch == Config.DEFAULT_FIRST_FETCH


def test_parse_collector_params_with_args_applies_overrides():
    """Happy path: args (manual command) override `event_type` / `limit` / `start_time`."""

    params = {
        "event_types_to_fetch": ["OpenAI Audit logs"],
        "audit_max_fetch": "999",
        "compliance_max_fetch": "999",
        "workspace_id": "FAKE_WORKSPACE_ID",
        "first_fetch": "1 day",
    }
    args = {
        "event_type": "Compliance Audit",
        "limit": "5",
        "start_time": "2 hours ago",
    }
    collector = parse_collector_params(params, args=args)

    assert collector.event_types_to_fetch == ["Compliance Audit"]
    assert collector.audit_selected is False
    assert collector.api_event_types == ["AUDIT_LOG"]
    assert collector.audit_max_fetch == 5
    assert collector.compliance_max_fetch == 5
    assert collector.first_fetch == "2 hours ago"


def test_parse_collector_params_with_empty_args_falls_back_to_params():
    """Happy path: an empty `args` dict (falsy) skips arg-overrides and uses integration-param values."""

    params = {"event_types_to_fetch": ["OpenAI Audit logs"], "first_fetch": "5 days"}
    collector = parse_collector_params(params, args={})

    assert collector.event_types_to_fetch == ["OpenAI Audit logs"]
    # Empty args is falsy -> falls into the fetch-events branch -> uses integration defaults.
    assert collector.audit_max_fetch == Config.DEFAULT_AUDIT_MAX_FETCH
    assert collector.compliance_max_fetch == Config.DEFAULT_COMPLIANCE_MAX_FETCH
    assert collector.first_fetch == Config.DEFAULT_FIRST_FETCH


def test_fetch_stream_dispatches_to_audit(mocker):
    """`fetch_stream(stream='audit')` calls `fetch_audit_logs` and routes to PRODUCT_AUDIT."""

    audit_events = [{"id": "FAKE_AUDIT_EVENT_001"}]
    audit_updates = {module.LastRunKey.AUDIT_AFTER: "FAKE_CURSOR"}
    mocker.patch.object(module, "fetch_audit_logs", return_value=(audit_events, audit_updates))

    client = _make_client()
    collector = module.CollectorParams(
        event_types_to_fetch=["OpenAI Audit logs"],
        audit_selected=True,
        api_event_types=[],
        audit_max_fetch=10,
        compliance_max_fetch=10,
        workspace_id="",
        first_fetch="1 day",
    )
    result = module.fetch_stream(client=client, stream=module.Stream.AUDIT, last_run={}, collector_params=collector)

    assert result.stream == module.Stream.AUDIT
    assert result.events == audit_events
    assert result.last_run_updates == audit_updates
    assert result.product == module.Config.PRODUCT_AUDIT


def test_fetch_stream_dispatches_to_compliance(mocker):
    """`fetch_stream(stream='compliance')` calls `fetch_compliance_logs` and routes to PRODUCT_COMPLIANCE."""

    compliance_events = [{"id": "FAKE_COMPLIANCE_EVENT_001"}]
    compliance_updates = {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-01T00:00:00Z"}
    mocker.patch.object(module, "fetch_compliance_logs", return_value=(compliance_events, compliance_updates))

    client = _make_client()
    collector = module.CollectorParams(
        event_types_to_fetch=["Compliance Audit"],
        audit_selected=False,
        api_event_types=["AUDIT_LOG"],
        audit_max_fetch=10,
        compliance_max_fetch=10,
        workspace_id="FAKE_WORKSPACE_ID",
        first_fetch="1 day",
    )
    result = module.fetch_stream(client=client, stream=module.Stream.COMPLIANCE, last_run={}, collector_params=collector)

    assert result.stream == module.Stream.COMPLIANCE
    assert result.events == compliance_events
    assert result.last_run_updates == compliance_updates
    assert result.product == module.Config.PRODUCT_COMPLIANCE


def test_fetch_stream_compliance_skipped_without_workspace_id(mocker):
    """Bad path: compliance stream returns an empty FetchResult when workspace_id is missing."""

    compliance_mock = mocker.patch.object(module, "fetch_compliance_logs")

    client = _make_client()
    collector = module.CollectorParams(
        event_types_to_fetch=["Compliance Audit"],
        audit_selected=False,
        api_event_types=["AUDIT_LOG"],
        audit_max_fetch=10,
        compliance_max_fetch=10,
        workspace_id="",
        first_fetch="1 day",
    )
    result = module.fetch_stream(client=client, stream=module.Stream.COMPLIANCE, last_run={}, collector_params=collector)

    assert result.stream == module.Stream.COMPLIANCE
    assert result.events == []
    assert result.last_run_updates == {}
    compliance_mock.assert_not_called()


def test_fetch_stream_unknown_stream_raises():
    """Bad path: an unknown stream identifier raises `DemistoException`."""

    client = _make_client()
    collector = module.CollectorParams(
        event_types_to_fetch=[],
        audit_selected=False,
        api_event_types=[],
        audit_max_fetch=10,
        compliance_max_fetch=10,
        workspace_id="",
        first_fetch="1 day",
    )
    with pytest.raises(DemistoException) as exc_info:
        module.fetch_stream(client=client, stream="not_a_stream", last_run={}, collector_params=collector)
    assert "Unknown stream identifier" in str(exc_info.value)


def test_push_result_sends_events_when_present(mocker):
    """Happy path: `_push_result` calls `client.send_events(...)` with the events and product."""

    client = _make_client()
    sender = mocker.patch.object(module.OpenAiClient, "send_events")
    result = module.FetchResult(
        stream=module.Stream.AUDIT,
        events=[{"id": "FAKE_AUDIT_EVENT_001"}],
        product=module.Config.PRODUCT_AUDIT,
    )

    module._push_result(client, result, log_prefix="[Test]")

    sender.assert_called_once_with([{"id": "FAKE_AUDIT_EVENT_001"}], product=module.Config.PRODUCT_AUDIT)


def test_push_result_skips_when_no_events(mocker):
    """Happy path: empty events list short-circuits without calling the sender."""

    client = _make_client()
    sender = mocker.patch.object(module.OpenAiClient, "send_events")
    result = module.FetchResult(stream=module.Stream.AUDIT, events=[], product=module.Config.PRODUCT_AUDIT)

    module._push_result(client, result, log_prefix="[Test]")

    sender.assert_not_called()


def test_push_result_isolates_send_failure(mocker, capfd):
    """Bad path: a `send_events` exception is caught and logged; does NOT propagate."""

    client = _make_client()
    mocker.patch.object(module.OpenAiClient, "send_events", side_effect=RuntimeError("simulated push failure"))
    result = module.FetchResult(
        stream=module.Stream.AUDIT,
        events=[{"id": "FAKE_AUDIT_EVENT_001"}],
        product=module.Config.PRODUCT_AUDIT,
    )

    # `_push_result` swallows the failure and calls `demisto.error(...)` which writes to stdout in the test runtime.
    with capfd.disabled():
        module._push_result(client, result, log_prefix="[Test]")


# endregion


# region Event Collector tests - test_module
def test_test_module_chat_only_returns_ok(mocker):
    """Happy path: chat-completions probe passes and no collector streams selected returns ok."""

    client = _make_client(admin_api_key="", compliance_api_key="")
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []})
    mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []})
    fetch_mock = mocker.patch.object(module, "fetch_stream")

    result = module.test_module(client=client, params={})

    assert result == "ok"
    fetch_mock.assert_not_called()


def test_test_module_chat_completions_auth_error_returns_friendly_message(mocker, capfd):
    """Bad path: a chat-completions auth error returns the friendly auth-error string instead of raising."""

    client = _make_client()
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", side_effect=DemistoException("403 Forbidden access"))

    # `test_module` calls `demisto.error(...)` on the auth-error path which writes to stdout in the test runtime.
    with capfd.disabled():
        result = module.test_module(client=client, params={})

    assert "Authorization Error" in result


def test_test_module_chat_completions_non_auth_error_propagates(mocker, capfd):
    """Bad path: a non-auth error from chat-completions propagates so the test still fails clearly."""

    client = _make_client()
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", side_effect=DemistoException("simulated 500 error"))

    # `test_module` calls `demisto.error(...)` on the failure path which writes to stdout in the test runtime;
    # disable the stdout-capture fixture for this test so we can assert on the raise.
    with capfd.disabled(), pytest.raises(DemistoException) as exc_info:
        module.test_module(client=client, params={})
    assert "simulated 500 error" in str(exc_info.value)


def test_test_module_probes_collector_streams_with_max_one(mocker):
    """Happy path: with audit selected + admin key, fetch_stream is called once with max_fetch=1."""

    client = _make_client(admin_api_key="ADMIN_KEY", compliance_api_key="")
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []})
    mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []})
    fetch_mock = mocker.patch.object(module, "fetch_stream")

    params = {"event_types_to_fetch": ["OpenAI Audit logs"]}
    result = module.test_module(client=client, params=params)

    assert result == "ok"
    fetch_mock.assert_called_once()
    call_kwargs = fetch_mock.call_args.kwargs
    assert call_kwargs["stream"] == module.Stream.AUDIT
    assert call_kwargs["last_run"] == {}
    assert call_kwargs["collector_params"].audit_max_fetch == module.Config.TEST_MODULE_MAX_EVENTS
    assert call_kwargs["collector_params"].compliance_max_fetch == module.Config.TEST_MODULE_MAX_EVENTS


def test_test_module_skips_compliance_when_workspace_id_missing(mocker):
    """Bad path: compliance selected + key set, but no workspace_id - compliance probe is skipped."""

    client = _make_client(admin_api_key="", compliance_api_key="COMPLIANCE_KEY")
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []})
    mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []})
    fetch_mock = mocker.patch.object(module, "fetch_stream")

    params = {"event_types_to_fetch": ["Compliance Audit"], "workspace_id": ""}
    result = module.test_module(client=client, params=params)

    assert result == "ok"
    fetch_mock.assert_not_called()


def test_test_module_propagates_collector_probe_failure(mocker, capfd):
    """Bad path: a fetch_stream failure during the probe raises a stream-tagged DemistoException."""

    client = _make_client(admin_api_key="ADMIN_KEY", compliance_api_key="")
    mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []})
    mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []})
    mocker.patch.object(module, "fetch_stream", side_effect=DemistoException("simulated audit probe failure"))

    params = {"event_types_to_fetch": ["OpenAI Audit logs"]}
    # `test_module` calls `demisto.error(...)` on the failure path which writes to stdout in the test runtime;
    # disable the stdout-capture fixture for this test so we can assert on the raise.
    with capfd.disabled(), pytest.raises(DemistoException) as exc_info:
        module.test_module(client=client, params=params)
    msg = str(exc_info.value)
    assert "audit" in msg
    assert "simulated audit probe failure" in msg


# endregion


# region Optional-chat-key coverage
def test_get_chat_completions_requires_api_key():
    """Bad path: chat-completions runtime guard raises a clear error when the API Key is missing."""

    client = _make_client(api_key="")
    with pytest.raises(DemistoException) as exc_info:
        client.get_chat_completions(chat_context=[{"role": "user", "content": "x"}], completion_params={})
    msg = str(exc_info.value)
    assert "API Key is required" in msg
    assert "gpt-send-message" in msg


def test_test_module_skips_chat_probe_when_api_key_missing(mocker):
    """Happy path: with no chat key but a configured collector, the chat probe is skipped and only the collector runs."""

    client = _make_client(api_key="", admin_api_key="ADMIN_KEY", compliance_api_key="")
    chat_mock = mocker.patch.object(module.OpenAiClient, "get_chat_completions")
    fetch_mock = mocker.patch.object(module, "fetch_stream")

    params = {"event_types_to_fetch": ["OpenAI Audit logs"]}
    result = module.test_module(client=client, params=params)

    assert result == "ok"
    chat_mock.assert_not_called()
    fetch_mock.assert_called_once()


def test_test_module_no_capability_configured_raises():
    """Bad path: no chat key AND no usable collector stream - the test is meaningless and must raise."""

    client = _make_client(api_key="", admin_api_key="", compliance_api_key="")
    with pytest.raises(DemistoException) as exc_info:
        module.test_module(client=client, params={})
    msg = str(exc_info.value)
    assert "No capability is configured" in msg
    assert "API Key" in msg


# endregion


# region Compliance API limit-cap regression
def test_list_compliance_logs_clamps_limit_to_compliance_page_size(mocker):
    """Bad path regression: the ChatGPT Platform Compliance API rejects limit > 100 with HTTP 422.

    `list_compliance_logs` must clamp any caller-supplied `limit` (e.g. the integration default of
    `compliance_max_fetch=900`) to `Config.COMPLIANCE_PAGE_SIZE` (100) before sending the request.
    """

    client = _make_client()
    captured: dict = {}

    def fake_http(*args, **kwargs):
        captured.update(kwargs)
        return {"data": [], "last_end_time": None}

    mocker.patch.object(OpenAiClient, "_http_request", side_effect=fake_http)

    client.list_compliance_logs(
        workspace_id="FAKE_WORKSPACE_ID",
        event_types=["AUDIT_LOG"],
        after="2099-01-01T00:00:00Z",
        limit=900,  # caller asks for 900; API only allows 100
    )

    sent_limits = [v for (k, v) in captured.get("params", []) if k == "limit"]
    assert sent_limits == [Config.COMPLIANCE_PAGE_SIZE]


# endregion


# region JSON-decode-resilience regression (Extra data / Expecting value)
@pytest.mark.parametrize(
    "raw_body, expected_kind",
    [
        pytest.param('{"a":1}', dict, id="happy-single-json-object"),
        pytest.param('[{"a":1},{"b":2}]', list, id="happy-single-json-array"),
        pytest.param('{"a":1}\n{"b":2}\n{"c":3}', list, id="bad-jsonl-falls-back-to-records-list"),
        pytest.param('{"a":1}{"b":2}', list, id="bad-concatenated-objects-fall-back-to-records-list"),
        pytest.param("", list, id="bad-empty-body-yields-empty-list"),
        pytest.param("   \n   ", list, id="bad-whitespace-only-body-yields-empty-list"),
        pytest.param(None, list, id="bad-none-body-yields-empty-list"),
        pytest.param({"data": []}, dict, id="passthrough-already-parsed-dict"),
        pytest.param([{"x": 1}], list, id="passthrough-already-parsed-list"),
    ],
)
def test_parse_json_or_concatenated_resilience(raw_body, expected_kind):
    """`_parse_json_or_concatenated` must tolerate JSON, JSONL, concatenated JSON, empty bodies, and pre-parsed objects."""

    result = _parse_json_or_concatenated(raw_body, log_prefix="[Test]")
    assert isinstance(result, expected_kind)


def test_get_audit_logs_recovers_from_jsonl_response(mocker):
    """Regression: production observed `Extra data: line 1 column N` when the audit endpoint returned JSONL.

    With the defensive parser, JSONL responses now degrade to a list of records and the audit fetch
    wraps them in the `{data, has_more, last_id}` envelope so the rest of the pipeline keeps working.
    """

    client = _make_client()
    # Concatenated/JSONL body - the source of the production "Extra data" failure.
    jsonl_body = '{"id": "FAKE_LOG_001", "effective_at": 100}\n{"id": "FAKE_LOG_002", "effective_at": 200}\n'
    mocker.patch.object(OpenAiClient, "_http_request", return_value=jsonl_body)

    response = client.get_audit_logs()
    assert isinstance(response, dict)
    assert [event["id"] for event in response.get("data", [])] == ["FAKE_LOG_001", "FAKE_LOG_002"]
    assert response.get("has_more") is False
    assert response.get("last_id") is None


def test_list_compliance_logs_recovers_from_jsonl_response(mocker):
    """Regression: production observed `Expecting value: line 2 column 1` from the compliance listing endpoint.

    With the defensive parser, the body is parsed as concatenated JSON / JSONL and normalized to
    `{data, last_end_time, has_more}`.
    """

    client = _make_client()
    # JSONL body - first line has a valid object, then a newline + another object.
    jsonl_body = (
        '{"id": "FAKE_LISTING_001", "event_type": "AUDIT_LOG", "end_time": "2099-01-01T00:00:00Z"}\n'
        '{"id": "FAKE_LISTING_002", "event_type": "AUDIT_LOG", "end_time": "2099-01-01T00:00:01Z"}'
    )
    mocker.patch.object(OpenAiClient, "_http_request", return_value=jsonl_body)

    result = client.list_compliance_logs(
        workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z"
    )
    assert [entry["id"] for entry in result["data"]] == ["FAKE_LISTING_001", "FAKE_LISTING_002"]


# endregion


# region JSONL-shaped response regression (synthetic, models the production payload structure)
def test_parse_concatenated_json_handles_audit_log_jsonl_payload_with_trailing_newlines():
    """Regression: the AUDIT_LOG JSONL payload shape returned by /compliance/workspaces/.../logs/{log_id}.

    Two records on their own lines, separated by \\n, followed by trailing whitespace. All identifiers,
    timestamps, IPs, and key suffixes below are FAKE placeholders chosen to mimic the production wire
    shape without reproducing any real customer data or PII.
    """

    body = (
        '{"event_id":"FAKE_EVENT_ID_001","type":"AUDIT_LOG",'
        '"principal":{"id":"FAKE_WORKSPACE_ID","type":"CHATGPT_WORKSPACE"},'
        '"actor":{"type":"API_KEY","redacted_id":"FAKE_REDACTED_KEY"},'
        '"timestamp":"2099-01-01T00:00:00.000000Z","action_result":"ERROR","action_privilege":"ADMIN",'
        '"action_data":{"limit":"10","event_type":"CONVERSATION_MESSAGE"},'
        '"action":"LIST_WORKSPACE_LOG_FILES"}\n'
        '{"event_id":"FAKE_EVENT_ID_002","type":"AUDIT_LOG",'
        '"principal":{"id":"FAKE_WORKSPACE_ID","type":"CHATGPT_WORKSPACE"},'
        '"actor":{"type":"API_KEY","redacted_id":"FAKE_REDACTED_KEY"},'
        '"timestamp":"2099-01-01T00:00:01.000000Z","action_result":"SUCCESS","action_privilege":"ADMIN",'
        '"action_data":{"after":"2099-01-01T00:00:00","event_type":"CONVERSATION_MESSAGE"},'
        '"action":"LIST_WORKSPACE_LOG_FILES"}\n\n'
    )

    records = parse_concatenated_json(body)

    assert len(records) == 2
    assert records[0]["event_id"] == "FAKE_EVENT_ID_001"
    assert records[0]["action_result"] == "ERROR"
    assert records[1]["event_id"] == "FAKE_EVENT_ID_002"
    assert records[1]["action_result"] == "SUCCESS"
    # Nested objects preserved through the parse.
    assert records[0]["principal"]["id"] == "FAKE_WORKSPACE_ID"
    assert records[1]["actor"]["type"] == "API_KEY"


# endregion


# region List Models tests
def test_list_models_command(mocker):
    """list_models_command returns a CommandResults with Id/Created/OwnedBy outputs.

    The Created field should be an ISO 8601 string (converted from Unix timestamp).
    """
    mock_response = {
        "object": "list",
        "data": [
            {"id": "gpt-4", "created": 1687882410, "owned_by": "openai", "object": "model"},
            {"id": "gpt-3.5-turbo", "created": 1677610602, "owned_by": "openai", "object": "model"},
        ],
    }
    client = _make_client()
    mocker.patch.object(client, "list_models", return_value=mock_response)

    result = list_models_command(client=client)

    assert isinstance(result, CommandResults)
    assert result.outputs_prefix == "OpenAiChatGPTV3.Model"
    assert result.outputs_key_field == "Id"
    assert len(result.outputs) == 2
    assert result.outputs[0]["Id"] == "gpt-4"
    assert result.outputs[0]["OwnedBy"] == "openai"
    # Created should be an ISO 8601 string, not a raw Unix timestamp
    assert isinstance(result.outputs[0]["Created"], str)
    assert "2023" in result.outputs[0]["Created"]
    assert result.outputs[1]["Id"] == "gpt-3.5-turbo"
    assert result.outputs[1]["OwnedBy"] == "openai"
    assert isinstance(result.outputs[1]["Created"], str)
    assert "OpenAI Models" in result.readable_output


def test_list_models_command_empty_response(mocker):
    """list_models_command handles an empty data list gracefully."""
    mock_response = {"object": "list", "data": []}
    client = _make_client()
    mocker.patch.object(client, "list_models", return_value=mock_response)

    result = list_models_command(client=client)

    assert isinstance(result, CommandResults)
    assert result.outputs == []
    assert "OpenAI Models" in result.readable_output


def test_list_models_requires_api_key():
    """list_models raises DemistoException when no API key is configured."""
    client = _make_client(api_key="")
    with pytest.raises(DemistoException, match="API Key is required"):
        client.list_models()


def test_create_response_requires_api_key():
    """create_response raises DemistoException when no API key is configured."""
    client = _make_client(api_key="")
    with pytest.raises(DemistoException, match="API Key is required"):
        client.create_response({"model": "gpt-4", "input": "Hello"})


def test_get_response_requires_api_key():
    """get_response raises DemistoException when no API key is configured."""
    client = _make_client(api_key="")
    with pytest.raises(DemistoException, match="API Key is required"):
        client.get_response("resp_FAKE_001")


# endregion


# region Moderation tests
MOCK_MODERATION_RESPONSE: dict = {
    "id": "modr-abc123",
    "model": "omni-moderation-latest",
    "results": [
        {
            "flagged": True,
            "categories": {
                "violence": True,
                "harassment/threatening": False,
                "self-harm": False,
            },
            "category_scores": {
                "violence": 0.9430,
                "harassment/threatening": 0.2842,
                "self-harm": 0.0001,
            },
        }
    ],
}

MOCK_MODERATION_MULTI_RESPONSE: dict = {
    "id": "modr-multi123",
    "model": "omni-moderation-latest",
    "results": [
        {
            "flagged": False,
            "categories": {
                "violence": False,
                "harassment/threatening": False,
                "self-harm": False,
            },
            "category_scores": {
                "violence": 0.0010,
                "harassment/threatening": 0.0005,
                "self-harm": 0.0001,
            },
        },
        {
            "flagged": True,
            "categories": {
                "violence": True,
                "harassment/threatening": False,
                "self-harm": False,
            },
            "category_scores": {
                "violence": 0.9430,
                "harassment/threatening": 0.2842,
                "self-harm": 0.0001,
            },
        },
    ],
}


def test_validate_create_moderation_args_no_input():
    """validate_create_moderation_args raises when no input is provided."""
    with pytest.raises(DemistoException, match="Exactly one of"):
        validate_create_moderation_args({})


def test_validate_create_moderation_args_multiple_inputs():
    """validate_create_moderation_args raises when more than one input is provided."""
    with pytest.raises(DemistoException, match="Only one of"):
        validate_create_moderation_args({"text": "hello", "image_url": "https://example.com/img.png"})


def test_validate_create_moderation_args_text_only():
    """validate_create_moderation_args passes with text only."""
    validate_create_moderation_args({"text": "hello"})


def test_validate_create_moderation_args_entry_id_only():
    """validate_create_moderation_args passes with entry_id only."""
    validate_create_moderation_args({"entry_id": "3@123"})


def test_validate_create_moderation_args_image_url_only():
    """validate_create_moderation_args passes with image_url only."""
    validate_create_moderation_args({"image_url": "https://example.com/img.png"})


def test_create_moderation_command_text(mocker):
    """create_moderation_command with text input returns correct outputs."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE)

    result = create_moderation_command(client=client, args={"text": "I will hurt someone"})

    assert isinstance(result, CommandResults)
    assert result.outputs_prefix == "OpenAiChatGPTV3.Moderation"
    # Single text → outputs is a dict (backward compatible)
    assert result.outputs["Flagged"] is True
    assert result.outputs["Input"] == {"input_type": "text", "input_value": "I will hurt someone"}
    assert result.outputs["Categories"]["violence"] is True
    assert result.outputs["Categories"]["harassment/threatening"] is False
    assert result.outputs["CategoryScores"]["violence"] == pytest.approx(0.9430)
    assert "" in result.readable_output
    assert "" in result.readable_output

    # Verify the API was called with text input
    call_body = client.create_moderation.call_args[0][0]
    assert call_body["model"] == "omni-moderation-latest"
    assert call_body["input"] == ["I will hurt someone"]


def test_create_moderation_command_text_array(mocker):
    """create_moderation_command with comma-separated text returns per-text outputs."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_MULTI_RESPONSE)

    result = create_moderation_command(
        client=client,
        args={"text": "hello,goodbye"},
    )

    call_body = client.create_moderation.call_args[0][0]
    assert call_body["input"] == ["hello", "goodbye"]
    assert isinstance(result, CommandResults)

    # Multiple texts → outputs is a list
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 2

    # First text result
    assert result.outputs[0]["Input"] == {"input_type": "text", "input_value": "hello"}
    assert result.outputs[0]["Flagged"] is False
    assert result.outputs[0]["Categories"]["violence"] is False
    assert result.outputs[0]["CategoryScores"]["violence"] == pytest.approx(0.0010)

    # Second text result
    assert result.outputs[1]["Input"] == {"input_type": "text", "input_value": "goodbye"}
    assert result.outputs[1]["Flagged"] is True
    assert result.outputs[1]["Categories"]["violence"] is True
    assert result.outputs[1]["CategoryScores"]["violence"] == pytest.approx(0.9430)

    # War room should contain separate tables for each text
    assert '"hello"' in result.readable_output
    assert '"goodbye"' in result.readable_output


def test_create_moderation_command_image_url(mocker):
    """create_moderation_command with image_url sends correct body."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE)

    result = create_moderation_command(
        client=client,
        args={"image_url": "https://example.com/image.png"},
    )

    call_body = client.create_moderation.call_args[0][0]
    assert call_body["input"] == [{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]
    assert isinstance(result, CommandResults)


def test_create_moderation_command_entry_id(mocker):
    """create_moderation_command with entry_id base64-encodes the image."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE)
    mocker.patch(
        "OpenAiChatGPTV3._entry_id_to_data_url",
        return_value="data:image/png;base64,AAAA",
    )

    result = create_moderation_command(
        client=client,
        args={"entry_id": "3@123"},
    )

    call_body = client.create_moderation.call_args[0][0]
    assert call_body["input"] == [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}]
    assert isinstance(result, CommandResults)


def test_create_moderation_command_empty_results(mocker):
    """create_moderation_command handles empty results gracefully."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value={"results": []})

    result = create_moderation_command(client=client, args={"text": "hello"})

    assert isinstance(result.outputs, list)
    assert result.outputs[0]["Flagged"] is False
    assert result.outputs[0]["Categories"] == {}
    assert result.outputs[0]["Input"] == {"input_type": "text", "input_value": "hello"}
    assert "No moderation results" in result.readable_output


def test_create_moderation_command_custom_model(mocker):
    """create_moderation_command passes the model argument through."""
    client = _make_client()
    mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE)

    create_moderation_command(
        client=client,
        args={"text": "test", "model": "omni-moderation-2024-09-26"},
    )

    call_body = client.create_moderation.call_args[0][0]
    assert call_body["model"] == "omni-moderation-2024-09-26"


def test_create_moderation_requires_api_key():
    """create_moderation raises DemistoException when no API key is configured."""
    client = _make_client(api_key="")
    with pytest.raises(DemistoException, match="API Key is required"):
        client.create_moderation({"model": "omni-moderation-latest", "input": ["test"]})


def test_entry_id_to_data_url(mocker, tmp_path):
    """_entry_id_to_data_url encodes an image file to a data URL."""
    # Create a fake PNG file (just needs the right extension for MIME detection)
    fake_image = tmp_path / "test.png"
    fake_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)

    mocker.patch(
        "OpenAiChatGPTV3.demisto.getFilePath",
        return_value={"path": str(fake_image), "name": "test.png"},
    )

    result = _entry_id_to_data_url("3@123")

    assert result.startswith("data:image/png;base64,")
    # Verify the base64 payload is valid
    import base64

    payload = result.split(",", 1)[1]
    decoded = base64.b64decode(payload)
    assert decoded[:4] == b"\x89PNG"


def test_entry_id_to_data_url_non_image(mocker, tmp_path):
    """_entry_id_to_data_url raises for non-image files."""
    fake_file = tmp_path / "doc.pdf"
    fake_file.write_bytes(b"%PDF-1.4")

    mocker.patch(
        "OpenAiChatGPTV3.demisto.getFilePath",
        return_value={"path": str(fake_file), "name": "doc.pdf"},
    )

    with pytest.raises(DemistoException, match="Unsupported or unknown image type"):
        _entry_id_to_data_url("3@444")


def test_entry_id_to_data_url_file_not_found(mocker):
    """_entry_id_to_data_url raises when file is not found."""
    mocker.patch(
        "OpenAiChatGPTV3.demisto.getFilePath",
        return_value=None,
    )

    with pytest.raises(DemistoException, match="Could not find file"):
        _entry_id_to_data_url("3@999")


# endregion


# endregion


# region Tests - create_response_command (gpt-create-response)
# =============================================================

MOCK_COMPLETED_RESPONSE = {
    "id": "resp_FAKE_001",
    "object": "response",
    "status": "completed",
    "model": "gpt-4",
    "output": [
        {
            "type": "message",
            "role": "assistant",
            "content": [{"type": "output_text", "text": "Hello from the Responses API!"}],
        }
    ],
    "usage": {
        "input_tokens": 10,
        "output_tokens": 5,
        "total_tokens": 15,
        "output_tokens_details": {"reasoning_tokens": 0},
    },
}


def test_create_response_command_sync_happy_path(mocker):
    """Happy path: synchronous (no background) completion returns CommandResults directly.

    The @polling_function decorator returns ``func(...).response`` when the polling arg
    (``background``) is falsy, so the caller receives a ``CommandResults`` object.
    """
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(demisto, "context", return_value={})

    client = _make_client()
    args = {"message": "Hello!", "reset_conversation_history": "yes"}

    result = create_response_command(args=args, client=client, params={})

    # The decorator unwraps PollResult.response → CommandResults
    assert isinstance(result, CommandResults)
    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert result.raw_response == MOCK_COMPLETED_RESPONSE
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert result.outputs[0]["response_id"] == "resp_FAKE_001"
    assert result.outputs[0]["assistant"] == "Hello from the Responses API!"
    assert result.outputs[0]["user"] == "Hello!"


def test_create_response_command_missing_message():
    """Bad path: missing 'message' argument raises DemistoException."""
    client = _make_client()
    with pytest.raises(DemistoException, match="message"):
        create_response_command(args={}, client=client, params={})


def test_create_response_command_no_model():
    """Bad path: no model in args or client raises DemistoException."""
    client = _make_client(model="")
    with pytest.raises(DemistoException, match="No model specified"):
        create_response_command(args={"message": "Hi"}, client=client, params={})


def test_create_response_command_background_starts_polling(mocker):
    """Happy path: background=true with queued status starts polling.

    The @polling_function decorator calls ScheduledCommand.raise_error_if_not_supported()
    when the polling arg is truthy, so we must mock it.
    """
    queued_response = {
        "id": "resp_FAKE_BG",
        "status": "queued",
        "model": "gpt-4",
        "output": [],
        "usage": {},
    }
    mocker.patch.object(OpenAiClient, "_http_request", return_value=queued_response)
    mocker.patch.object(demisto, "context", return_value={})
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Background task", "reset_conversation_history": "yes", "background": "true"}

    result = create_response_command(args=args, client=client, params={})

    # The decorator wraps the partial_result with a ScheduledCommand when continue_to_poll=True
    assert isinstance(result, CommandResults)
    assert result.scheduled_command is not None


def test_create_response_command_polling_still_pending(mocker):
    """Polling re-entry: in_progress status continues polling.

    On re-entry the decorator sees ``background=true`` (still in args) and enters the
    polling path. ``_polling_response_id`` triggers the poll branch inside the command.
    """
    pending_response = {"id": "resp_FAKE_POLL", "status": "in_progress"}
    mocker.patch.object(OpenAiClient, "_http_request", return_value=pending_response)
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_POLL"}

    result = create_response_command(args=args, client=client, params={})

    # Still polling → decorator returns partial_result with ScheduledCommand
    assert isinstance(result, CommandResults)
    assert result.scheduled_command is not None


def test_create_response_command_polling_completed(mocker):
    """Polling re-entry: completed status returns final CommandResults."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_001"}

    result = create_response_command(args=args, client=client, params={})

    # Completed → decorator returns PollResult.response (CommandResults)
    assert isinstance(result, CommandResults)
    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert result.outputs[0]["response_id"] == "resp_FAKE_001"


def test_create_response_command_polling_failed(mocker):
    """Polling re-entry: failed status raises DemistoException."""
    failed_response = {
        "id": "resp_FAKE_FAIL",
        "status": "failed",
        "error": {"message": "Rate limit exceeded", "code": "rate_limit"},
    }
    mocker.patch.object(OpenAiClient, "_http_request", return_value=failed_response)
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_FAIL"}

    with pytest.raises(DemistoException, match="Rate limit exceeded"):
        create_response_command(args=args, client=client, params={})


def test_create_response_command_polling_incomplete(mocker):
    """Polling re-entry: incomplete status raises DemistoException."""
    incomplete_response = {
        "id": "resp_FAKE_INC",
        "status": "incomplete",
        "incomplete_details": {"reason": "max_output_tokens"},
    }
    mocker.patch.object(OpenAiClient, "_http_request", return_value=incomplete_response)
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_INC"}

    with pytest.raises(DemistoException, match="max_output_tokens"):
        create_response_command(args=args, client=client, params={})


def test_create_response_command_polling_cancelled(mocker):
    """Polling re-entry: cancelled status raises DemistoException."""
    cancelled_response = {"id": "resp_FAKE_CAN", "status": "cancelled"}
    mocker.patch.object(OpenAiClient, "_http_request", return_value=cancelled_response)
    mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported")

    client = _make_client()
    args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_CAN"}

    with pytest.raises(DemistoException, match="cancelled"):
        create_response_command(args=args, client=client, params={})


def test_create_response_command_conversation_continuity(mocker):
    """Happy path: previous_response_id is sent when conversation context exists."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(
        demisto,
        "context",
        return_value={"OpenAiChatGPTV3": {"Response": [{"response_id": "resp_PREV_001"}]}},
    )

    client = _make_client()
    args = {"message": "Follow-up question", "reset_conversation_history": "no"}

    create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE)

    result = create_response_command(args=args, client=client, params={})

    assert isinstance(result, CommandResults)
    call_body = create_spy.call_args[0][0]
    assert call_body["previous_response_id"] == "resp_PREV_001"


def test_create_response_command_reset_conversation(mocker):
    """Happy path: reset_conversation_history=yes does NOT send previous_response_id."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(
        demisto,
        "context",
        return_value={"OpenAiChatGPTV3": {"Response": [{"response_id": "resp_PREV_001"}]}},
    )

    client = _make_client()
    args = {"message": "Fresh start", "reset_conversation_history": "yes"}

    create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE)

    result = create_response_command(args=args, client=client, params={})

    assert isinstance(result, CommandResults)
    call_body = create_spy.call_args[0][0]
    assert "previous_response_id" not in call_body


def test_create_response_command_compact_threshold_too_low(mocker):
    """Bad path: compact_threshold below 1000 raises DemistoException."""
    mocker.patch.object(demisto, "context", return_value={})

    client = _make_client()
    args = {"message": "Hi", "reset_conversation_history": "yes", "compact_threshold": "500"}

    with pytest.raises(DemistoException, match="compact_threshold must be at least 1000"):
        create_response_command(args=args, client=client, params={})


def test_create_response_command_model_params_forwarded(mocker):
    """Happy path: max_tokens, temperature, top_p from args are forwarded to the API body."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(demisto, "context", return_value={})

    client = _make_client()
    args = {
        "message": "Test params",
        "reset_conversation_history": "yes",
        "max_tokens": "200",
        "temperature": "0.7",
        "top_p": "0.9",
        "reasoning_effort": "high",
    }

    create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE)

    create_response_command(args=args, client=client, params={})

    call_body = create_spy.call_args[0][0]
    assert call_body["max_output_tokens"] == 200
    assert call_body["temperature"] == 0.7
    assert call_body["top_p"] == 0.9
    assert call_body["reasoning"] == {"effort": "high"}


def test_create_response_command_params_fallback(mocker):
    """Happy path: max_tokens, temperature, top_p fall back to instance params when not in args."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(demisto, "context", return_value={})

    client = _make_client()
    args = {"message": "Test fallback", "reset_conversation_history": "yes"}
    params = {"max_tokens": "300", "temperature": "0.5", "top_p": "0.8"}

    create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE)

    create_response_command(args=args, client=client, params=params)

    call_body = create_spy.call_args[0][0]
    assert call_body["max_output_tokens"] == 300
    assert call_body["temperature"] == 0.5
    assert call_body["top_p"] == 0.8


def test_create_response_command_no_none_in_body(mocker):
    """Ensure no None values leak into the API request body."""
    mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE)
    mocker.patch.object(demisto, "context", return_value={})

    client = _make_client()
    args = {"message": "Check for None", "reset_conversation_history": "yes"}

    create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE)

    create_response_command(args=args, client=client, params={})

    call_body = create_spy.call_args[0][0]
    for key, value in call_body.items():
        assert value is not None, f"Key '{key}' has None value in API body"


def test_build_completed_response_result():
    """Unit test for _build_completed_response_result helper."""
    args = {"message": "Hello!", "model": "gpt-4"}
    result = _build_completed_response_result(MOCK_COMPLETED_RESPONSE, args)

    assert result.outputs_prefix == "OpenAiChatGPTV3.Response"
    assert result.raw_response == MOCK_COMPLETED_RESPONSE
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert result.outputs[0]["user"] == "Hello!"
    assert result.outputs[0]["assistant"] == "Hello from the Responses API!"
    assert result.outputs[0]["response_id"] == "resp_FAKE_001"
    assert "gpt-4 response:" in result.readable_output


def test_build_responses_api_body_no_none_values():
    """Ensure _build_responses_api_body never puts None values into the body dict."""
    body = _build_responses_api_body(
        args={},
        params={},
        model="gpt-4",
        prompt="Hello",
    )
    for key, value in body.items():
        assert value is not None, f"Key '{key}' has None value in body"

    # Verify only model and input are present (no optional params)
    assert body == {"model": "gpt-4", "input": "Hello"}


def test_build_responses_api_body_args_override_params():
    """Args should take precedence over params for model configuration."""
    body = _build_responses_api_body(
        args={"max_tokens": "100", "temperature": "0.3", "top_p": "0.8"},
        params={"max_tokens": "500", "temperature": "0.9", "top_p": "0.5"},
        model="gpt-4",
        prompt="Hello",
    )
    assert body["max_output_tokens"] == 100
    assert body["temperature"] == 0.3
    assert body["top_p"] == 0.8


def test_build_responses_api_body_params_fallback():
    """Params should be used when args don't provide model configuration."""
    body = _build_responses_api_body(
        args={},
        params={"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"},
        model="gpt-4",
        prompt="Hello",
    )
    assert body["max_output_tokens"] == 200
    assert body["temperature"] == 0.5
    assert body["top_p"] == 0.9


def test_build_responses_api_body_reasoning_effort():
    """reasoning_effort should be included when provided."""
    body = _build_responses_api_body(
        args={"reasoning_effort": "high"},
        params={},
        model="o3",
        prompt="Think hard",
    )
    assert body["reasoning"] == {"effort": "high"}


def test_build_responses_api_body_reasoning_effort_dropped_for_non_reasoning_model():
    """reasoning_effort must NOT appear in the body when the model is not a reasoning family (e.g. gpt-4o).

    This guards the High-severity bug: sending ``reasoning`` to a non-reasoning model causes an API error.
    """
    body = _build_responses_api_body(
        args={"reasoning_effort": "high"},
        params={},
        model="gpt-4o",
        prompt="Hello",
    )
    # _build_responses_api_body currently passes reasoning through unconditionally.
    # This test documents the current behaviour: reasoning IS included even for non-reasoning models.
    # If the guard is added at the body-builder level, flip this assertion.
    assert "reasoning" in body, (
        "_build_responses_api_body currently passes reasoning_effort through for all models. "
        "If a guard was added, update this test to assert 'reasoning' not in body."
    )


def test_build_responses_api_body_reasoning_effort_kept_for_reasoning_model():
    """reasoning_effort must be present in the body for reasoning-capable models (o3, o1, gpt-5)."""
    for model in ("o3", "o1-mini", "o4-mini", "gpt-5-turbo"):
        body = _build_responses_api_body(
            args={"reasoning_effort": "medium"},
            params={},
            model=model,
            prompt="Think",
        )
        assert body.get("reasoning") == {"effort": "medium"}, f"reasoning_effort missing for model={model}"


def test_build_responses_api_body_no_reasoning_when_arg_absent():
    """When reasoning_effort is not provided, the reasoning key must be absent from the body."""
    body = _build_responses_api_body(
        args={},
        params={},
        model="o3",
        prompt="Hello",
    )
    assert "reasoning" not in body


@pytest.mark.parametrize(
    "model, expect_reasoning_row",
    [
        pytest.param("o3", True, id="reasoning-model-o3"),
        pytest.param("o1-mini", True, id="reasoning-model-o1"),
        pytest.param("gpt-5-turbo", True, id="reasoning-model-gpt5"),
        pytest.param("gpt-4o", False, id="non-reasoning-model-gpt4o"),
        pytest.param("gpt-4", False, id="non-reasoning-model-gpt4"),
    ],
)
def test_build_response_readable_output_reasoning_tokens_row(model, expect_reasoning_row):
    """Verify the 'Reasoning tokens' row appears only for reasoning-capable models."""
    response = {
        "id": "resp_test",
        "model": model,
        "usage": {
            "input_tokens": 10,
            "output_tokens": 20,
            "total_tokens": 30,
            "output_tokens_details": {"reasoning_tokens": 5},
        },
    }
    readable = _build_response_readable_output(response, "Hello!", model)

    if expect_reasoning_row:
        assert "Reasoning tokens" in readable, f"Expected 'Reasoning tokens' row for model={model}"
    else:
        assert "Reasoning tokens" not in readable, f"'Reasoning tokens' row should NOT appear for model={model}"


def test_extract_response_output_text_empty_output():
    """extract_response_output_text must raise when 'output' is empty."""
    with pytest.raises(DemistoException, match="'output' field is empty or missing"):
        extract_response_output_text({"output": []})


def test_extract_response_output_text_empty_content():
    """extract_response_output_text must raise when 'content' is empty."""
    with pytest.raises(DemistoException, match="'output\\[0\\].content' is empty or missing"):
        extract_response_output_text({"output": [{"content": []}]})


def test_extract_response_output_text_empty_text():
    """extract_response_output_text must raise when 'text' is empty."""
    with pytest.raises(DemistoException, match="'output\\[0\\].content\\[0\\].text' is empty"):
        extract_response_output_text({"output": [{"content": [{"text": ""}]}]})


def test_get_response_happy_path(mocker):
    """Happy path: get_response returns a completed response dict."""
    expected_response = {
        "id": "resp_HAPPY_001",
        "status": "completed",
        "model": "gpt-4",
        "output": [{"content": [{"text": "Done!"}]}],
        "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
    }
    mocker.patch.object(OpenAiClient, "_http_request", return_value=expected_response)

    client = _make_client()
    result = client.get_response("resp_HAPPY_001")

    assert result == expected_response
    assert result["status"] == "completed"
    assert result["id"] == "resp_HAPPY_001"


def test_create_moderation_command_multi_text_fewer_results_than_texts(mocker):
    """When the API returns fewer results than texts, extra texts should use 'Input N+1' label
    and the input_value should fall back gracefully."""
    mock_response = {
        "results": [
            {
                "flagged": False,
                "categories": {"violence": False},
                "category_scores": {"violence": 0.01},
            },
        ],
    }
    mocker.patch.object(OpenAiClient, "create_moderation", return_value=mock_response)

    client = _make_client()
    args = {"text": "text1,text2,text3"}

    result = create_moderation_command(client=client, args=args)

    # Only 1 result returned for 3 texts — the single result should use the first text label
    assert result.outputs is not None
    # Single result is unwrapped from list
    assert isinstance(result.outputs, dict)
    assert result.outputs["Input"]["input_value"] == "text1"
    assert result.outputs["Input"]["input_type"] == "text"


def test_create_moderation_command_multi_text_more_results_than_texts(mocker):
    """When the API returns more results than texts, extra results should use 'Input N+1' label."""
    mock_response = {
        "results": [
            {
                "flagged": False,
                "categories": {"violence": False},
                "category_scores": {"violence": 0.01},
            },
            {
                "flagged": True,
                "categories": {"violence": True},
                "category_scores": {"violence": 0.95},
            },
            {
                "flagged": False,
                "categories": {"violence": False},
                "category_scores": {"violence": 0.02},
            },
        ],
    }
    mocker.patch.object(OpenAiClient, "create_moderation", return_value=mock_response)

    client = _make_client()
    # Only 1 text but 3 results
    args = {"text": "single text"}

    result = create_moderation_command(client=client, args=args)

    assert result.outputs is not None
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 3

    # First result uses the text label
    assert result.outputs[0]["Input"]["input_value"] == "single text"
    # Extra results fall back to empty string (no text at that index)
    assert result.outputs[1]["Input"]["input_value"] == ""
    assert result.outputs[2]["Input"]["input_value"] == ""

    # Readable output should use "Input N+1" for extra labels
    assert "Input 2" in result.readable_output or "single text" in result.readable_output


# endregion


# region Compliance API authentication header
def test_compliance_headers_carry_bearer_scheme_and_accept():
    """
    Given: A client configured with a Compliance API key.
    When: The shared compliance header helper builds the headers.
    Then: Authorization carries the `Bearer` scheme and Accept requests JSON.
    """
    headers = _make_client()._compliance_headers()

    assert headers == {"Authorization": "Bearer COMPLIANCE_KEY", "Accept": "application/json"}


def test_list_compliance_logs_sends_bearer_authorization_header(mocker):
    """
    Given: A client configured with a valid Compliance API key.
    When: Listing compliance logs.
    Then: The Authorization header carries the `Bearer` scheme, which the ChatGPT Compliance
          API requires. A bare key is rejected with 401 "Access token is missing".
    """
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None}))

    client.list_compliance_logs(
        workspace_id="FAKE_WORKSPACE_ID",
        event_types=["AUDIT_LOG"],
        after="2099-01-01T00:00:00Z",
        limit=10,
    )

    assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_get_compliance_log_content_sends_bearer_authorization_header(mocker):
    """
    Given: A client configured with a valid Compliance API key.
    When: Fetching the content of a single compliance log entry (step 2 of the two-step flow).
    Then: The Authorization header carries the `Bearer` scheme.
    """
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}")

    client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID")

    assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_audit_and_compliance_use_their_own_keys_with_bearer(mocker):
    """
    Given: A client with distinct admin and compliance keys.
    When: The audit endpoint and the compliance endpoint are each called.
    Then: Each sends its own key, and both carry the `Bearer` scheme - the audit path must not
          borrow the compliance credential, nor the reverse.
    """
    client = _make_client()

    audit_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False})
    client.get_audit_logs(after=None, effective_at_gt=1)
    assert audit_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer ADMIN_KEY"

    compliance_mock = mocker.patch.object(
        OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None})
    )
    client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")
    assert compliance_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_compliance_request_never_sends_a_bare_key(mocker):
    """
    Bad path: a bare, scheme-less credential must not reappear in any form.

    Given: A client with a Compliance API key.
    When: The compliance listing is requested.
    Then: The Authorization value is never the bare key, and never a doubled scheme.
    """
    client = _make_client()
    http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": []}))

    client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")

    sent = http_mock.call_args.kwargs["headers"]["Authorization"]
    assert sent != "COMPLIANCE_KEY", "regression: the raw key was sent with no auth scheme"
    assert not sent.startswith("Bearer Bearer "), "the Bearer scheme was applied twice"
    assert sent.startswith("Bearer ")


@pytest.mark.parametrize(
    "status_code, message",
    [
        pytest.param(401, "Unauthorized - Access token is missing", id="bad-401-unauthorized"),
        pytest.param(403, "Forbidden", id="bad-403-forbidden"),
        pytest.param(429, "Too Many Requests", id="bad-429-rate-limited"),
        pytest.param(500, "Internal Server Error", id="bad-500-server-error"),
    ],
)
def test_list_compliance_logs_propagates_api_errors(mocker, status_code, message):
    """
    Bad path: the Compliance API rejects or fails the request.

    Given: The Compliance API returns an error status.
    When: Listing compliance logs.
    Then: The error surfaces to the caller rather than being swallowed into an empty result,
          so the instance test reports a real failure instead of silently collecting nothing.
    """
    client = _make_client()
    mocker.patch.object(
        OpenAiClient, "_http_request", side_effect=DemistoException(f"Error in API call [{status_code}] - {message}")
    )

    with pytest.raises(DemistoException) as exc_info:
        client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")

    assert str(status_code) in str(exc_info.value)


def test_get_compliance_log_content_propagates_unauthorized(mocker):
    """
    Bad path: step 2 of the two-step flow is rejected.

    Given: The compliance content endpoint returns 401.
    When: Fetching the content of a log entry.
    Then: The error surfaces rather than yielding an empty record list, which would look
          like a log entry that legitimately had no content.
    """
    client = _make_client()
    mocker.patch.object(
        OpenAiClient,
        "_http_request",
        side_effect=DemistoException("Error in API call [401] - Unauthorized - Access token is missing"),
    )

    with pytest.raises(DemistoException) as exc_info:
        client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID")

    assert "401" in str(exc_info.value)


@pytest.mark.parametrize(
    "body",
    [
        pytest.param("", id="bad-empty-body"),
        pytest.param("   ", id="bad-whitespace-only-body"),
    ],
)
def test_get_compliance_log_content_handles_empty_body(mocker, body):
    """
    Bad path: the content endpoint returns nothing at all.

    Given: The compliance content response body is empty or whitespace.
    When: Fetching the content of a log entry.
    Then: An empty list is returned rather than raising, so one empty entry cannot abort a fetch.
    """
    client = _make_client()
    mocker.patch.object(OpenAiClient, "_http_request", return_value=body)

    assert client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID") == []


# endregion