AggregatedCommandApiModule

Common Aggregated Command API Module, provides generic Infrastructure for different kinds of aggregated commands.

python · ApiModules

Details

IDAggregatedCommandApiModule
Languagepython
From Version6.10.0
Docker Imagedemisto/python3:3.12.11.4819260
import pytest
import demistomock as demisto
from CommonServerPython import DemistoException, entryTypes
from AggregatedCommandApiModule import *
from datetime import datetime, timedelta, UTC


# =================================================================================================
# == Test Helper Functions
# =================================================================================================
class DummyModule(AggregatedCommand):
    def process_batch_results(self, execution_results):
        pass

    def run(self):
        pass


def stub_modules(mocker, modules_list):
    """
    modules_list: list of dicts with 'brand' and 'state'
    demisto.getModules() will return a dict mapping keys to those dicts.
    """
    fake = {f"m{i}": m for i, m in enumerate(modules_list)}
    mocker.patch.object(demisto, "getModules", return_value=fake)


def build_ioc(
    scores: dict,
    *,
    value: str = "indicator1",
    score: int | None = None,
    custom_fields: dict | None = None,
) -> dict:
    """
    Build a TIM IOC shaped for process_tim_results/create_tim_indicator.
    - scores: mapping of brand -> { "score": int, "context": {...} }
    - value: indicator value
    - score: overall TIM score (optional)
    - custom_fields: lower-cased custom fields (optional)
    """
    out = {
        "value": value,
        "insightCache": {"scores": scores or {}},
    }
    if score is not None:
        out["score"] = score
    if custom_fields is not None:
        out["CustomFields"] = custom_fields
    return out


def as_map(merged_list, value_key):
    """Turn the merged list into {value: results} for easy assertions."""
    return {item[value_key]: item["Results"] for item in merged_list}


def make_entry_result(name, brand, status, msg):
    """Creates an entry result."""
    return EntryResult(command_name=name, args="", brand=brand, status=status, message=msg)


# =================================================================================================
# == Global Mocks & Fixtures
# =================================================================================================
default_indicator = IndicatorSchema(
    type="indicator", value_field="Value", context_path_prefix="Indicator(", context_output_mapping={"Score": "Score"}
)


@pytest.fixture
def module_factory():
    """
    Pytest fixture that returns a factory function for creating
    ReputationAggregatedCommand instances with default or custom arguments.
    """

    def _factory(**kwargs):
        """The actual factory function."""
        factory_defaults = {
            "args": {},
            "brands": [],
            "indicator_schema": default_indicator,
            "indicator_instances": [],
            "final_context_path": "ctx",
            "external_enrichment": False,
            "additional_fields": False,
            "verbose": False,
            "commands": [[]],
        }
        factory_defaults.update(kwargs)
        return ReputationAggregatedCommand(**factory_defaults)

    return _factory


# =================================================================================================
# == Unit Tests
# =================================================================================================


# -------------------------------------------------------------------------------------------------
# -- Level 1: Standalone Helper Functions
# -------------------------------------------------------------------------------------------------
# --- Validation Tests ---
@pytest.mark.parametrize(
    "values, mapping, expected",
    [
        # Match first value
        (["md5", "sha256"], {"MD5": "md5"}, "md5"),
        # Match second value
        (["md5", "sha256"], {"SHA256": "sha256"}, "sha256"),
        # Case-insensitive match
        (["AaA"], {"MD5": "aAa"}, "AaA"),
    ],
)
def test_map_back_to_input_basic(values, mapping, expected):
    """
    Given:
        - A list of original values and a mapping of hash field -> value.
    When:
        - Calling map_back_to_input.
    Then:
        - Returns the first original value that matches one of the mapping values (case-insensitive),
          or empty string if none match.
    """
    assert map_back_to_input(values, mapping) == expected


@pytest.mark.parametrize(
    "data, indicator_type, extracted, expected_set",
    [
        # Exact match
        (["https://a.com"], "URL", {"URL": ["https://a.com"]}, {"https://a.com"}),
        # Case-insensitive type
        (["https://a.com"], "url", {"URL": ["https://a.com"]}, {"https://a.com"}),
        # Duplicates in input are OK → output is the deduped extracted set
        (
            ["https://a.com", "https://b.com", "https://b.com"],
            "URL",
            {"URL": ["https://a.com", "https://b.com"]},
            {"https://a.com", "https://b.com"},
        ),
        # Irrelevant types can be present; we only take the requested type
        (["https://a.com"], "URL", {"URL": ["https://a.com"], "Domain": ["irrelevant.com"]}, {"https://a.com"}),
    ],
)
def test_extract_input_success_sets_data(mocker, data, indicator_type, extracted, expected_set):
    """
    Given:
        - Different input lists and extractIndicators outputs.
    When:
        - Calling extract_input.
    Then:
        - self.data is replaced with the deduped extracted set for the requested type.
        - No exception is raised.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": extracted}}],
    )
    instances, _ = create_and_extract_indicators(data, indicator_type)
    assert {instance.extracted_value for instance in instances} == expected_set


@pytest.mark.parametrize(
    "data, indicator_type, extracted",
    [
        # No key for the requested type at all
        (["https://a.com"], "URL", {"Domain": ["a.com"]}),
        # Requested key present but empty
        (["https://a.com", "https://b.com"], "URL", {"URL": []}),
        # Completely empty extraction
        (["https://a.com", "https://b.com"], "URL", {}),
    ],
)
def test_create_and_extract_indicators_raises_when_no_valid_indicators(mocker, data, indicator_type, extracted):
    """
    Given:
        - extractIndicators returns no items for the requested indicator type.
    When:
        - Calling create_and_extract_indicators.
    Then:
        - Raises ValueError("No valid indicators found in the input data.").
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": extracted}}],
    )

    with pytest.raises(ValueError, match="No valid indicators found in the input data."):
        data, _ = create_and_extract_indicators(data, indicator_type)


def test_create_and_extract_indicators_raises_on_empty_result(mocker):
    """
    Given:
        - execute_command('extractIndicators', ...) returns a empty result.
    When:
        - Calling create_and_extract_indicators.
    Then:
        - Raises DemistoException with a validation failure message.
    """
    mocker.patch("AggregatedCommandApiModule.execute_command", return_value=[])

    with pytest.raises(ValueError, match="No valid indicators found in the input data."):
        create_and_extract_indicators(["https://a.com"], "url")


def test_mismatched_types_allowed_when_flag_false(mocker):
    """
    Given:
        - extractIndicators returns both the expected type and another type.
        - mark_mismatched_type_as_invalid = False.
    When:
        - Calling create_and_extract_indicators.
    Then:
        - The input is treated as valid.
        - Only the expected type is turned into IndicatorInstance objects.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[
            {
                "EntryContext": {
                    "ExtractedIndicators": {
                        "URL": ["https://a.com"],
                        "IP": ["1.1.1.1"],
                    }
                }
            }
        ],
    )

    instances, _ = create_and_extract_indicators(
        ["https://a.com"],
        "URL",
        mark_mismatched_type_as_invalid=False,
    )

    assert len(instances) == 1
    assert instances[0].extracted_value == "https://a.com"
    assert getattr(instances[0], "final_status", None) in (None, Status.SUCCESS)


def test_mismatched_types_marked_invalid_when_flag_true(mocker):
    """
    Given:
        - extractIndicators returns both the expected type and another type.
        - mark_mismatched_type_as_invalid = True.
    When:
        - Calling _process_single_input.
    Then:
        - The input is treated as invalid.
        - A failure IndicatorInstance with hr_message="Invalid" is created.
    """
    from AggregatedCommandApiModule import _process_single_input

    mocker.patch(
        "AggregatedCommandApiModule._execute_extraction",
        return_value=(
            {"URL": ["example"], "IP": ["1.1.1.1"]},
            "HR_FRAGMENT",
            None,
        ),
    )

    valid_set: set[str] = set()
    invalid_set: set[str] = set()

    instances, hr = _process_single_input(
        raw="example",
        expected_type_lower="url",
        mark_mismatched_type_as_invalid=True,
        valid_set=valid_set,
        invalid_set=invalid_set,
    )

    assert hr == "HR_FRAGMENT"
    assert len(instances) == 1

    inst = instances[0]
    assert inst.raw_input == "example"
    assert inst.hr_message == "Invalid"
    assert inst.final_status == Status.FAILURE

    assert "example" in invalid_set
    assert not valid_set  # no valid indicators were added


def test_process_single_input_marks_failure_on_extraction_error(mocker):
    """
    Given:
        - _execute_extraction returns an error_message for the raw input.
    When:
        - Calling _process_single_input.
    Then:
        - A failure IndicatorInstance is created with context_message
          'extractIndicators Failed' and the error_message set.
        - The raw input is added to invalid_set.
        - valid_set remains unchanged.
    """
    from AggregatedCommandApiModule import _process_single_input

    mocker.patch(
        "AggregatedCommandApiModule._execute_extraction",
        return_value=({}, "HR_FRAGMENT", "boom error"),
    )

    valid_set: set[str] = set()
    invalid_set: set[str] = set()

    instances, hr = _process_single_input(
        raw="example",
        expected_type_lower="url",
        mark_mismatched_type_as_invalid=False,
        valid_set=valid_set,
        invalid_set=invalid_set,
    )

    assert hr == "HR_FRAGMENT"
    assert len(instances) == 1

    inst = instances[0]
    assert inst.raw_input == "example"
    assert inst.context_message == "extractIndicators Failed"
    assert inst.error_message == "boom error"
    assert inst.final_status == Status.FAILURE

    assert "example" in invalid_set
    assert not valid_set


@pytest.mark.parametrize(
    "raw, expected_type_lower, extracted_ctx, mark_invalid_flag, is_valid, expected_values",
    [
        # 1) Exact match
        ("https://a.com", "url", {"URL": ["https://a.com"]}, False, True, {"https://a.com"}),
        # 2) Case-insensitive type match
        ("https://a.com", "URL", {"URL": ["https://a.com"]}, False, True, {"https://a.com"}),
        # 3) Expected + other types → still valid when flag=False
        ("https://a.com", "url", {"URL": ["https://a.com"], "IP": ["1.1.1.1"]}, False, True, {"https://a.com"}),
        # 4) Only other types → invalid
        ("a", "url", {"Domain": ["a.com"]}, False, False, set()),
        # 5) Expected + other types → invalid when flag=True
        ("https://a.com", "url", {"URL": ["https://a.com"], "IP": ["1.1.1.1"]}, True, False, set()),
    ],
)
def test_process_single_input_parametrized(
    mocker, raw, expected_type_lower, extracted_ctx, mark_invalid_flag, is_valid, expected_values
):
    """
    Given:
        - A raw input and an extractIndicators context.
    When:
        - Calling _process_single_input with different combinations of expected types
          and the mark_mismatched_type_as_invalid flag.
    Then:
        - Valid inputs produce IndicatorInstances with extracted_value.
        - Invalid inputs produce a failure IndicatorInstance.
        - valid_set / invalid_set are updated correctly.
    """
    from AggregatedCommandApiModule import _process_single_input

    # Mock extraction to return our parametrized extracted context
    mocker.patch(
        "AggregatedCommandApiModule._execute_extraction",
        return_value=(extracted_ctx, "HR_FRAGMENT", None),
    )

    valid_set = set()
    invalid_set = set()

    instances, hr = _process_single_input(
        raw=raw,
        expected_type_lower=expected_type_lower.lower(),
        mark_mismatched_type_as_invalid=mark_invalid_flag,
        valid_set=valid_set,
        invalid_set=invalid_set,
    )

    assert hr == "HR_FRAGMENT"

    if is_valid:
        # Should produce expected extracted values
        assert {inst.extracted_value for inst in instances} == expected_values
        assert raw not in invalid_set
        assert valid_set == expected_values
    else:
        # Should produce exactly one invalid instance
        assert len(instances) == 1
        inst = instances[0]
        assert inst.final_status == Status.FAILURE
        assert raw in invalid_set
        assert not valid_set


def test_split_expected_and_other_types_only_expected(mocker):
    """
    Given:
        - extractIndicators returns only the expected type.
    When:
        - Calling create_and_extract_indicators.
    Then:
        - The indicators are treated as valid and returned as instances.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[
            {
                "EntryContext": {
                    "ExtractedIndicators": {
                        "URL": ["https://a.com", "https://b.com"],
                    }
                }
            }
        ],
    )

    instances, _ = create_and_extract_indicators(
        ["https://a.com"],
        "URL",
        mark_mismatched_type_as_invalid=True,  # should still be fine, no other types
    )

    values = {inst.extracted_value for inst in instances}
    assert values == {"https://a.com", "https://b.com"}


def test_mixed_valid_and_error_inputs(mocker):
    """
    Given:
        - Two inputs: one 'good' and one 'bad'.
        - 'good' returns a valid URL.
        - 'bad' raises an error in extractIndicators.
    When:
        - Calling create_and_extract_indicators.
    Then:
        - No exception is raised.
        - We get one successful IndicatorInstance for the valid URL.
        - We get one failure IndicatorInstance for the error case.
        - The markdown HR contains both inputs and the error message.
    """

    def _exec_side_effect(command, args, extract_contents=False):
        assert command == "extractIndicators"
        text = args["text"]
        if text == "google":
            return [
                {
                    "EntryContext": {
                        "ExtractedIndicators": {
                            "URL": ["google"],
                        }
                    }
                }
            ]
        if text == "bad":
            raise Exception("extract failed for bad")
        raise AssertionError(f"Unexpected input: {text}")

    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        side_effect=_exec_side_effect,
    )

    instances, hr = create_and_extract_indicators(["google", "bad"], "URL")

    # 1) Valid instances
    valid_values = {i.extracted_value for i in instances if getattr(i, "extracted_value", None) is not None}
    assert valid_values == {"google"}

    # 2) Error / failure instance
    failure_instances = [i for i in instances if getattr(i, "final_status", None) == Status.FAILURE]
    assert len(failure_instances) == 1
    failure = failure_instances[0]
    assert failure.raw_input == "bad"
    assert failure.context_message == "extractIndicators Failed"
    assert failure.error_message == "extract failed for bad"

    # 3) HR contains both sections and the error
    assert "Result for name=extractIndicators args='text': google" in hr
    assert "Result for name=extractIndicators args='text': bad" in hr
    assert "Error Message: extract failed for bad" in hr


@pytest.mark.parametrize(
    "dict1, dict2, expected",
    [
        # non-overlapping keys → dict1 extended with dict2
        (
            {"a": {"X": [1]}},
            {"b": {"Y": [2, 3]}},
            {"a": {"X": [1]}, "b": {"Y": [2, 3]}},
        ),
        # overlapping key with nested dict → lists extended
        (
            {"a": {"X": [1], "Z": [9]}},
            {"a": {"X": [2, 3]}},
            {"a": {"X": [1, 2, 3], "Z": [9]}},
        ),
        # overlapping key where dict2 value is not dict → extend at top level
        (
            {"a": [1]},
            {"a": [2, 3]},
            {"a": [1, 2, 3]},
        ),
    ],
)
def test_deep_merge_in_place(dict1, dict2, expected):
    """
    Given:
        - Two dictionaries to merge.
    When:
        - Calling deep_merge_in_place.
    Then:
        - The first dictionary is merged with the second dictionary.
    """
    deep_merge_in_place(dict1, dict2)
    assert dict1 == expected


@pytest.mark.parametrize(
    "nested, expected",
    [
        ([], []),
        ([1, 2, 3], [1, 2, 3]),
        ([1, [2, 3], [[4], 5]], [1, 2, 3, 4, 5]),
        ([[[[]]]], []),
    ],
)
def test_flatten_list(nested, expected):
    """
    Given:
        - A nested list.
    When:
        - Calling flatten_list.
    Then:
        - The list is flattened.
    """
    assert flatten_list(nested) == expected


@pytest.mark.parametrize(
    "initial, path, value, expected",
    [
        ({}, "A", 1, {"A": 1}),
        ({}, "A..B..C", "x", {"A": {"B": {"C": "x"}}}),
        ({}, "List..Items[]", 5, {"List": {"Items": [5]}}),
        ({"List": {"Items": [1]}}, "List..Items[]", 2, {"List": {"Items": [1, 2]}}),
    ],
)
def test_set_dict_value(initial, path, value, expected):
    """
    Given:
        - A dictionary to modify.
        - A path to set the value at.
        - A value to set.
    When:
        - Calling set_dict_value.
    Then:
        - The dictionary is modified.
    """
    set_dict_value(initial, path, value)
    assert initial == expected


@pytest.mark.parametrize(
    "initial, path, expected_value, expected_remaining",
    [
        ({"A": 1}, "A", 1, {}),
        ({"A": {"B": 2}}, "A..B", 2, {"A": {}}),
        ({"X": 1}, "A..B", None, {"X": 1}),
        ({"A": {"C": 3}}, "A..B", None, {"A": {"C": 3}}),
    ],
)
def test_pop_dict_value(initial, path, expected_value, expected_remaining):
    """
    Given:
        - A dictionary to modify.
        - A path to get the value at.
    When:
        - Calling get_and_remove_dict_value.
    Then:
        - The dictionary is modified.
    """
    result = pop_dict_value(initial, path)
    assert result == expected_value
    assert initial == expected_remaining


@pytest.mark.parametrize(
    "input_val, expected",
    [
        (None, False),
        ([], False),
        ([{"Type": entryTypes["note"]}], False),
        ([{"Type": entryTypes["debug"]}], True),
        ({"Type": entryTypes["debug"]}, True),
    ],
)
def test_is_debug_various(input_val, expected):
    """
    Given:
        - A list of entries.
    When:
        - Calling is_debug.
    Then:
        - The list is flattened.
    """
    assert is_debug_entry(input_val) is expected


@pytest.mark.parametrize(
    "data, exceptions, expected",
    [
        # --- Case 1: simple dict with None, empty list, empty dict ---
        ({"a": None, "b": [], "c": {}, "d": 1}, None, {"d": 1}),
        # --- Case 2: keep None if key in exceptions ---
        ({"a": None, "b": [], "c": {}, "d": 1}, {"a"}, {"a": None, "d": 1}),
        # --- Case 3: nested dict with empty values ---
        ({"outer": {"inner1": None, "inner2": []}, "x": "val"}, None, {"x": "val"}),
        # --- Case 4: nested dict with exceptions ---
        ({"outer": {"inner1": None, "inner2": []}, "x": "val"}, {"inner1"}, {"outer": {"inner1": None}, "x": "val"}),
        # --- Case 5: list of dicts ---
        ([{"a": None}, {"b": 2}, {}, []], None, [{"b": 2}]),
        # --- Case 6: list of dicts with exceptions ---
        ([{"a": None}, {"b": 2}, {}, []], {"a"}, [{"a": None}, {"b": 2}]),
        # --- Case 7: deeply nested structures ---
        ({"lvl1": {"lvl2": {"lvl3": None}}, "keep": "ok"}, None, {"keep": "ok"}),
        ({"lvl1": {"lvl2": {"lvl3": None}}, "keep": "ok"}, {"lvl3"}, {"lvl1": {"lvl2": {"lvl3": None}}, "keep": "ok"}),
    ],
)
def test_remove_empty_elements_with_exceptions(data, exceptions, expected):
    """
    Given:
        - A dictionary or list with None, empty dicts/lists, and some values.
        - Optionally, an exceptions set specifying which keys should not be removed.
    When:
        - remove_empty_elements_with_exceptions is called.
    Then:
        - The output should have empty values stripped unless they are in exceptions.
    """
    result = remove_empty_elements_with_exceptions(data, exceptions)
    assert result == expected


@pytest.mark.parametrize(
    "input_val, expected",
    [
        ("d41d8cd98f00b204e9800998ecf8427e", {"MD5": "d41d8cd98f00b204e9800998ecf8427e"}),
        ("da39a3ee5e6b4b0d3255bfef95601890afd80709", {"SHA1": "da39a3ee5e6b4b0d3255bfef95601890afd80709"}),
        (None, {}),
        ("", {}),
    ],
)
def test_build_hash_dict(input_val, expected):
    """
    Given:
        - A hash string (MD5 or SHA1) or empty.
    When:
        - build_hash_dict is called.
    Then:
        - Returns a dict with the correct hash type key (upper case).
    """
    # Note: 'get_hash_type' comes from CommonServerPython.
    # If mocking is needed, patch it. Usually it's available in unit tests via import.
    assert build_hash_dict(input_val) == expected


# -------------------------------------------------------------------------------------------------
# -- Level 2: Core Class Units (Command + EntryResult)
# -------------------------------------------------------------------------------------------------


def test_entry_result_to_entry():
    """
    Given:
        - An EntryResult instance.
    When:
        - Calling to_entry.
    Then:
        - Returns a dictionary with the expected structure.
    """
    entry_result = EntryResult(
        command_name="test-command",
        args={"arg1": "value1"},
        brand="test-brand",
        status=Status.SUCCESS,
        message="test-message",
    )
    entry = entry_result.to_entry()
    assert entry == {
        "Arguments": {"arg1": "value1"},
        "Brand": "test-brand",
        "Status": "Success",
        "Message": "test-message",
    }


@pytest.mark.parametrize(
    "brands_to_run, ignore_using_brand, expected_result",
    [
        (["brand1", "brand2"], False, {"test-command": {"arg1": "value1", "using-brand": "brand1,brand2"}}),
        ([], False, {"test-command": {"arg1": "value1"}}),
        (["brand1", "brand2"], True, {"test-command": {"arg1": "value1"}}),
    ],
)
def test_to_batch_item_with_brands(brands_to_run, ignore_using_brand, expected_result):
    """
    Given:
        - A Command instance with name and args.
        - A list of brands to run (may be empty).
    When:
        - Calling to_batch_item method with brands.
    Then:
        - Returns a dictionary with the command name as key and args as value.
        - The using-brand parameter is added only when brands are provided.
    """
    cmd = Command(name="test-command", args={"arg1": "value1"}, ignore_using_brand=ignore_using_brand)
    batch_item = cmd.to_batch_item(brands_to_run)

    assert batch_item == expected_result


# -------------------------------------------------------------------------------------------------
# -- Level 3: Core Class Units (BatchExecutor)
# -------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
    "results, commands_list, expected_result",
    [
        (
            # One Command return 3 results
            [[{"Brand": "TIM", "Type": 1}, {"Brand": "brandA", "Type": 1}, {"Brand": "TIM", "Type": 1}]],
            [Command(name="test-command", args={"arg1": "value1"})],
            [
                [
                    ({"Brand": "TIM", "Type": 1}, "", ""),
                    ({"Brand": "brandA", "Type": 1}, "", ""),
                    ({"Brand": "TIM", "Type": 1}, "", ""),
                ]
            ],
        ),
        (
            # First and last Command dont return, middle command return 2 results
            [[], [{"Type": 1}, {"Type": 1}], []],
            [Command(name="test", args={"a": "a"}), Command(name="test", args={"a": "a"}), Command(name="test", args={"a": "a"})],
            [[], [({"Type": 1}, "", ""), ({"Type": 1}, "", "")], []],
        ),
        (
            # First command return 2 results one is debug, second Command return only debug
            [[{"Type": 16}, {"Type": 1}], [{"Type": 16}]],
            [Command(name="test", args={"a": "a"}), Command(name="test", args={"a": "a"})],
            [[({"Type": 1}, "", "")], []],
        ),
    ],
)
def test_batch_executer_process_results_various(results, commands_list, expected_result):
    """
    Given:
        - A BatchExecutor instance.
    When:
        - Calling process_results method with results and commands list.
    Then:
        - Returns a list of lists of tuples (result, hr_output, error_message).
    """
    batch_executor = BatchExecutor()
    processed_results = batch_executor.process_results(results, commands_list)
    assert processed_results == expected_result


# -------------------------------------------------------------------------------------------------
# -- Level 3: Core Class Units (ContextBuilder)
# -------------------------------------------------------------------------------------------------
def test_create_indicator_lifts_tim_fields_and_pops_from_tim_result():
    """
    Given:
        - tim_context with one indicator value containing a TIM result and two other brand results.
        - Indicator mapping includes both "Score" and "CVSS".
        - TIM result has Score, CVSS, Status, ModifiedTime.
    When:
        - create_indicator() is called.
    Then:
        - The returned item has top-level Value, TIMScore, TIMCVSS, Status, ModifiedTime.
        - The TIM entry inside "Results" had its Status and ModifiedTime popped out.
        - Non-TIM entries are unchanged.
    """
    indicator_schema = IndicatorSchema(
        type="url",
        value_field="Data",
        context_path_prefix="URL(",
        context_output_mapping={"Score": "Score", "CVSS": "CVSS"},
    )
    builder = ContextBuilder(indicator_schema=indicator_schema, final_context_path="X")
    tim = {"Brand": "TIM", "Score": 2, "CVSS": {"Score": 7.1}, "Status": "Fresh", "ModifiedTime": "2025-09-01T00:00:00Z"}
    brand_a = {"Brand": "A", "Score": 3, "Data": "a.com"}
    brand_b = {"Brand": "B", "Score": 1, "Data": "b.com"}
    instance = IndicatorInstance(
        raw_input="indicator1", extracted_value="indicator1", created=True, enriched=True, tim_context=[tim, brand_a, brand_b]
    )
    builder.add_indicator_instances([instance])
    out = builder.build_indicators_context()
    assert len(out) == 1
    item = out[0]
    # Top-level lifted fields
    assert item["Value"] == "indicator1"
    assert item["TIMScore"] == 2
    assert item["TIMCVSS"] == {"Score": 7.1}
    assert item["Status"] == "Fresh"
    assert item["ModifiedTime"] == "2025-09-01T00:00:00Z"
    # TIM result had Status/ModifiedTime popped
    tim_after = item["Results"][0]
    assert tim_after["Brand"] == "TIM"
    assert "Status" not in tim_after
    assert "ModifiedTime" not in tim_after
    # Non-TIM results untouched
    assert item["Results"][1] == brand_a
    assert item["Results"][2] == brand_b


def test_create_indicator_file_type_adds_hashes_from_tim():
    """
    Given:
        - A ContextBuilder for a file indicator with multiple hash fields.
        - TIM context with a TIM entry that has MD5, SHA1, and SHA256.
    When:
        - create_indicator() is called.
    Then:
        - The resulting item has:
            - Value set to the tim_context key.
            - A 'Hashes' dict with all available hash values.
            - Status and ModifiedTime lifted to the top level.
    """
    file_indicator = IndicatorSchema(
        type="file",
        value_field=["MD5", "SHA1", "SHA256"],
        context_path_prefix="File(",
        context_output_mapping={"Score": "Score"},
    )
    builder = ContextBuilder(
        indicator_schema=file_indicator,
        final_context_path="FileEnrichmentV2(val.Value && val.Value == obj.Value)",
    )

    tim_entry = {
        "Brand": "TIM",
        "MD5": "md5-value",
        "SHA1": "sha1-value",
        "SHA256": "sha256-value",
        "Score": 2,
        "Status": "Fresh",
        "ModifiedTime": "2025-09-01T00:00:00Z",
    }
    file_instance = IndicatorInstance(
        raw_input="file-indicator-key", extracted_value="file-indicator-key", enriched=True, created=True, tim_context=[tim_entry]
    )

    builder.add_indicator_instances([file_instance])

    out = builder.build_indicators_context()
    assert len(out) == 1
    item = out[0]

    # Top-level value should be the key from tim_context
    assert item["Value"] == "file-indicator-key"

    # Hashes aggregated from the TIM entry
    assert item["Hashes"] == {
        "MD5": "md5-value",
        "SHA1": "sha1-value",
        "SHA256": "sha256-value",
    }

    # Status / ModifiedTime lifted
    assert item["Status"] == "Fresh"
    assert item["ModifiedTime"] == "2025-09-01T00:00:00Z"


def test_add_other_commands_results():
    """
    Given:
        - A ContextBuilder instance.
    When:
        - add_other_commands_results is called multiple times.
    Then:
        - The internal other_context dictionary should be correctly updated.
    """
    builder = ContextBuilder(indicator_schema=default_indicator, final_context_path="Test.Path")

    builder.add_other_commands_results({"Command1": {"data": "value1"}})
    builder.add_other_commands_results({"Command2": {"data": "value2"}})

    assert builder.other_context == {"Command1": {"data": "value1"}, "Command2": {"data": "value2"}}


def test_build_preserves_exception_keys_when_empty():
    indicator = IndicatorSchema(
        type="url", value_field="Data", context_path_prefix="URL(", context_output_mapping={"Score": "Score", "CVSS": "CVSS"}
    )
    builder = ContextBuilder(indicator_schema=indicator, final_context_path="Test.Path")

    # TIM entry where TIM has no CVSS and explicit None ModifiedTime
    instance = IndicatorInstance(
        raw_input="v1",
        extracted_value="v1",
        enriched=True,
        created=True,
        tim_context=[{"Brand": "TIM", "Score": 2, "ModifiedTime": None, "CVSS": None, "Status": None}],
    )
    builder.add_indicator_instances([instance])

    final_context = builder.build()
    item = final_context["Test.Path(val.Value && val.Value == obj.Value)"][0]
    # Exceptions set in build(): {"TIMCVSS","Status","ModifiedTime"}
    # "ModifiedTime" is None → should be kept
    assert "ModifiedTime" in item
    assert item["ModifiedTime"] is None
    assert "TIMCVSS" in item
    assert item["TIMCVSS"] is None
    assert "Status" in item
    assert item["Status"] is None


def test_create_indicator_success_happy_path():
    """
    Given:
        - A Valid input (valid=True).
        - Successfully Enriched (enriched=True).
        - Found in TIM (found=True).
    When:
        - ContextBuilder.create_indicator() is called.
    Then:
        - The result should NOT be an Error.
        - The Status should be lifted from the TIM context (e.g., 'Fresh').
        - The Value should match the extracted value.
    """
    # 1. Setup Instance
    # "found=True" means tim_context is not None/Empty
    tim_data = {"Brand": "TIM", "Status": "Fresh", "Score": 2, "Value": "8.8.8.8"}

    instance = IndicatorInstance(
        raw_input="8.8.8.8",
        extracted_value="8.8.8.8",
        created=True,  # or False, doesn't matter if found+enriched
        enriched=True,
        tim_context=[tim_data],
    )

    # 2. Setup Builder
    # Schema doesn't matter much for the logic flow, just need defaults
    schema = IndicatorSchema("ip", "Address", "IP(", {})
    builder = ContextBuilder(schema, "IPEnrichment")
    builder.add_indicator_instances([instance])

    # 3. Execute
    results = builder.build_indicators_context()

    # 4. Assertions
    assert len(results) == 1
    res = results[0]

    assert res.get("Status") == "Fresh"  # Came from TIM object
    assert res.get("Value") == "8.8.8.8"
    assert res.get("Message") is None  # No error message on success


@pytest.mark.parametrize(
    "valid, created, enriched, found, expected_msg_part",
    [
        # --- Case 1: Invalid Input ---
        # Logic: if not valid -> Error
        # (Other flags don't matter)
        (False, False, False, False, "Invalid"),
        # --- Case 2: Creation Failed ---
        # Logic: not created and not enriched and not found -> probably not created
        (True, False, False, False, "Failed To Create"),
        # --- Case 3: Enrichment Failed (But Found) ---
        # Logic: not created and not enriched and found
        # (Means it existed in TIM, but the enrichment command failed/wasn't run)
        (True, False, False, True, "Failed to Enrich"),
        # --- Case 4: Enrichment Failed (Created) ---
        # Logic: created and not enriched
        # (Means we created it, but enrichment failed)
        (True, True, False, False, "Failed to Enrich"),
        (True, True, False, True, "Failed to Enrich"),  # Found doesn't matter if didn't enriched
        # --- Case 5: Extraction Failed ---
        # Logic: enriched and not found
        # (Means enrichment command said 'Success', but TIM search returned nothing)
        (True, True, True, False, "Failed to extract"),
        (True, False, True, False, "Failed to extract"),
    ],
)
def test_create_indicator_failure_scenarios(valid, created, enriched, found, expected_msg_part):
    """
    Given:
        - Various combinations of flags resulting in failure.
    When:
        - ContextBuilder.create_indicator() is called.
    Then:
        - The result Status should always be 'Error'.
        - The result Message should match the specific logic branch taken.
    """
    # 1. Setup Data
    extracted = "1.1.1.1" if valid else None
    tim_ctx = [{"Brand": "TIM", "Status": "Fresh"}] if found else None

    instance = IndicatorInstance(
        raw_input="raw_input", extracted_value=extracted, created=created, enriched=enriched, tim_context=tim_ctx, hr_message=""
    )
    # 2. Setup Builder
    schema = IndicatorSchema("ip", "Address", "IP(", {})
    builder = ContextBuilder(schema, "IPEnrichment")
    builder.add_indicator_instances([instance])

    # 3. Execute
    results = builder.build_indicators_context()

    # 4. Assertions
    assert len(results) == 1
    res = results[0]

    # All these cases must result in Error
    assert res.get("Status") == "Error"

    # Check that the specific error logic path was taken
    actual_msg = res.get("Message", "")
    assert expected_msg_part in actual_msg


# --- Tests for the build() method and its helpers ---
def test_build_extract_tim_score():
    """
    Given:
        - A list of indicator results from various brands.
    When:
        - The build() method is called.
    Then:
        - TIMScore is computed only from entries where Brand == "TIM", ignoring others.
    """
    indicator_with_score = IndicatorSchema(
        type="test",
        value_field="ID",
        context_path_prefix="Test(",
        context_output_mapping={"Score": "Score"},
    )
    builder = ContextBuilder(indicator_schema=indicator_with_score, final_context_path="Test.Path")
    instance = IndicatorInstance(
        raw_input="indicator1",
        extracted_value="indicator1",
        enriched=True,
        created=True,
        tim_context=[
            {"Score": 5, "Brand": "TIM"},
            {"Score": 8, "Brand": "brandA"},  # should be ignored for TIMScore
        ],
    )

    builder.add_indicator_instances([instance])
    # Only the TIM scores (5 and 3) should be considered => max is 5
    final_context = builder.build()
    final_indicator = final_context["Test.Path(val.Value && val.Value == obj.Value)"][0]

    assert final_indicator.get("TIMScore", 0) == 5


# --- 2) MaxScore/MaxVerdict enrichment over all results ---
@pytest.mark.parametrize(
    "results, expected_max, expected_verdict",
    [
        ([{"Score": 1, "Brand": "X"}, {"Score": 3, "Brand": "Y"}, {"Score": 2, "Brand": "TIM"}], 3, "Malicious"),
        ([{"Score": 2, "Brand": "X"}, {"Score": 2, "Brand": "TIM"}], 2, "Suspicious"),
        ([{"Score": 2, "Brand": "TIM"}], 2, "Suspicious"),
    ],
)
def test_build_enriches_final_indicators_correctly(results, expected_max, expected_verdict):
    """
    Given:
        - A set of indicator results.
    When:
        - The build() method is called.
    Then:
        - The final output is enriched with the correct MaxScore and MaxVerdict.
    """
    builder = ContextBuilder(indicator_schema=default_indicator, final_context_path="Test.Path")
    instance = IndicatorInstance(
        raw_input="indicator1", extracted_value="indicator1", enriched=True, created=True, tim_context=results
    )

    builder.add_indicator_instances([instance])

    final_context = builder.build()
    final_indicator = final_context["Test.Path(val.Value && val.Value == obj.Value)"][0]

    assert final_indicator["MaxScore"] == expected_max
    assert final_indicator["MaxVerdict"] == expected_verdict


def test_build_without_tim_context_carries_other():
    """
    Given:
        - No TIM context.
    When:
        - build() is called.
    Then:
        - Final context contains Other but no TIM key.
    """
    builder = ContextBuilder(indicator_schema=default_indicator, final_context_path="Final.Path")
    builder.add_other_commands_results({"K1": {"v": 2}})

    final_ctx = builder.build()
    assert "Final.Path(val.Value && val.Value == obj.Value)" not in final_ctx
    assert final_ctx["K1"]["v"] == 2


def test_build_assembles_all_context_types():
    """
    Given:
        - Data for TIM results, and other commands.
    When:
        - The build() method is called.
    Then:
        - The final context should contain all two types of data in the correct paths.
    """
    builder = ContextBuilder(indicator_schema=default_indicator, final_context_path="Test.Path")

    # Add all types of context
    builder.add_indicator_instances(
        [
            IndicatorInstance(
                raw_input="indicator1",
                extracted_value="indicator1",
                created=True,
                enriched=True,
                tim_context=[{"Score": 3, "Brand": "TIM"}],
            )
        ]
    )

    builder.add_other_commands_results({"Command1": {"data": "value1"}})

    final_context = builder.build()

    # Assert all parts are present
    assert "Test.Path(val.Value && val.Value == obj.Value)" in final_context
    assert final_context["Test.Path(val.Value && val.Value == obj.Value)"][0]["Value"] == "indicator1"
    assert "Command1" in final_context
    assert final_context["Command1"]["data"] == "value1"


# -------------------------------------------------------------------------------------------------
# -- Level 4: Core Class Units (BrandManager)
# -------------------------------------------------------------------------------------------------


def test_brand_manager_to_run_empty_means_all(mocker):
    """
    Given:
        - Active brands present.
        - No brands requested.
    When:
        - brand_manager.to_run is accessed.
    Then:
        - Returns [] to signal “all brands”.
    """
    stub_modules(mocker, [{"brand": "A", "state": "active"}, {"brand": "B", "state": "active"}])
    module = DummyModule(args={}, brands=[], verbose=False, commands=[])
    assert module.brand_manager.to_run == []


def test_enabled_brands_filters_only_active_brands(mocker):
    """
    Given:
        - demisto.getModules returns some active and inactive brands.
    When:
        - Accessing the enabled_brands property.
    Then:
        - Only the 'active' brands are returned, and a debug message logs the count.
    """
    stub_modules(
        mocker,
        [
            {"brand": "A", "state": "active"},
            {"brand": "B", "state": "inactive"},
            {"brand": "C", "state": "active"},
            {"brand": "D", "state": "unknown"},
        ],
    )
    module = DummyModule(args={}, brands=[], verbose=False, commands=[])
    result = module.brand_manager.enabled
    assert set(result) == {"A", "C"}


@pytest.mark.parametrize(
    "modules, input_brands, expected, exception",
    [
        # empty brand list → returns []
        (
            [{"brand": "X", "state": "active"}],
            [],
            [],
            None,
        ),
        # no overlap → raises DemistoException
        (
            [{"brand": "A", "state": "active"}],
            ["X", "Y"],
            None,
            DemistoException,
        ),
        # partial overlap → only A
        (
            [
                {"brand": "A", "state": "active"},
                {"brand": "B", "state": "active"},
            ],
            ["A", "C", "D"],
            ["A"],
            None,
        ),
    ],
)
def test_brands_to_run_various(mocker, modules, input_brands, expected, exception):
    """
    Given:
        - Some active brand.
        - A set of user-provided brands.
    When:
        - Accessing brands_to_run.
    Then:
        - Returns the expected list or raises the expected exception based on the intersection between both.
    """
    stub_modules(mocker, modules)
    module = DummyModule(args={}, brands=input_brands, verbose=False, commands=[])

    if exception:
        with pytest.raises(exception):
            _ = module.brand_manager.to_run
    else:
        result = module.brand_manager.to_run
        assert set(result) == set(expected)


@pytest.mark.parametrize(
    "modules, input_brands, expected_missing",
    [
        # no brands provided → always empty
        ([{"brand": "A", "state": "active"}], [], []),
        # one enabled, three requested → missing are the two not enabled
        ([{"brand": "A", "state": "active"}], ["A", "B", "C"], ["B", "C"]),
        # both A and B enabled, both requested → missing is empty
        (
            [
                {"brand": "A", "state": "active"},
                {"brand": "B", "state": "active"},
            ],
            ["A", "B"],
            [],
        ),
        # no modules enabled → all requested are missing
        ([], ["X", "Y"], ["X", "Y"]),
    ],
)
def test_missing_brands_various(mocker, modules, input_brands, expected_missing):
    """
    Given:
        - demisto.getModules returns a mixture of active/inactive brands.
        - A set of user-provided brands.
    When:
        - Accessing missing_brands.
    Then:
        - Returns the expected list of brands that are not enabled.
    """
    stub_modules(mocker, modules)
    module = DummyModule(args={}, brands=input_brands, verbose=False, commands=[])
    result = module.brand_manager.missing
    # order is not guaranteed, so compare as sets
    assert set(result) == set(expected_missing)


# -------------------------------------------------------------------------------------------------
# -- Level 5: AggregatedCommand
# -------------------------------------------------------------------------------------------------


@pytest.mark.parametrize(
    "enabled_brands, brands, commands_info, expected_unsupported_enrichment_brands",
    [
        # no brands → empty result
        ([], [], [], []),
        # one external command not enabled → one missing
        (
            ["A", "B"],
            ["A", "B", "C"],
            [("A", CommandType.INTERNAL), ("B", CommandType.INTERNAL), ("C", CommandType.EXTERNAL)],
            ["C"],
        ),
    ],
)
def test_unsupported_enrichment_brands_various(
    module_factory, enabled_brands, brands, commands_info, expected_unsupported_enrichment_brands
):
    """
    Given:
        - enabled_brands is mocked directly on the module.
        - module.brands and module.commands are overridden.
    When:
        - Accessing unsupported_enrichment_brands.
    Then:
        - Returns the expected list of external missing brands.
    """
    # instantiate with defaults
    module = module_factory(brands=brands)
    module.brand_manager.enabled = enabled_brands  # directly inject the enabled_brands list

    # override inputs
    module.commands = [
        [Command(name=f"cmd{i}", args={}, brand=brand, command_type=ctype) for i, (brand, ctype) in enumerate(commands_info)]
    ]

    result = module.unsupported_enrichment_brands
    assert set(result) == set(expected_unsupported_enrichment_brands)


# -------------------------------------------------------------------------------------------------
# -- Level 6: ReputationAggregatedCommand
# -------------------------------------------------------------------------------------------------


def test_internal_enrichment_brands_injected_when_no_brands_and_external_false(module_factory, mocker):
    """
    Given:
        - internal_enrichment_brands configured (e.g., WildFire).
        - No brands explicitly requested.
        - external_enrichment is False.
        - BrandManager.enabled_brands includes the internal enrichment brand.
        - There is also an INTERNAL command with its own brand.
    When:
        - Instantiating ReputationAggregatedCommand.
    Then:
        - self.brands should include both the internal_enrichment_brands and the INTERNAL command brands.
        - prepare_commands_batches should include both INTERNAL and EXTERNAL commands.
    """
    # Patch enabled_brands to simulate active integrations
    mocker.patch("AggregatedCommandApiModule.BrandManager.enabled_brands", return_value={"WildFire-v2", "OtherBrand"})

    cmd_internal = Command(
        name="core-get-hash-analytics-prevalence", args={}, brand="Cortex Core - IR", command_type=CommandType.INTERNAL
    )
    cmd_external = Command(name="enrichIndicators", args={}, command_type=CommandType.EXTERNAL)

    module = module_factory(
        brands=[],  # simulate user didn't pass brands
        external_enrichment=False,
        internal_enrichment_brands=["WildFire-v2"],
        indicator_schema=default_indicator,
        commands=[[cmd_internal, cmd_external]],
    )

    # Brands list should now be union of active internal_enrichment_brands and INTERNAL command brands
    assert set(module.brands) == {"WildFire-v2", "Cortex Core - IR"}

    # And prepare_commands_batches should now include both INTERNAL and EXTERNAL commands
    batches = module.prepare_commands_batches(external_enrichment=False)
    flattened = [c for batch in batches for c in batch]
    assert {c.name for c in flattened} == {"core-get-hash-analytics-prevalence", "enrichIndicators"}


def test_internal_enrichment_brands_not_applied_when_brands_given(module_factory, mocker):
    """
    Given:
        - internal_enrichment_brands configured.
        - User explicitly passes brands in the command args.
        - external_enrichment is False.
        - internal_enrichment_brands are enabled.
    When:
        - Instantiating ReputationAggregatedCommand.
    Then:
        - self.brands remains the user-provided brands (no auto-injection).
    """
    mocker.patch("AggregatedCommandApiModule.BrandManager.enabled_brands", return_value={"WildFire-v2"})

    cmd_internal = Command(name="intA", args={}, brand="A", command_type=CommandType.INTERNAL)
    module = module_factory(
        brands=["UserBrand"],
        external_enrichment=False,
        internal_enrichment_brands=["WildFire-v2"],
        indicator_schema=default_indicator,
        commands=[[cmd_internal]],
    )

    assert module.brands == ["UserBrand"]


# --- Prepare Commands Tests ---
@pytest.mark.parametrize(
    "requested_brands, external_enrichment, expected_names",
    [
        ([], False, {"intA", "intB"}),  # no brands, no external → INTERNAL only
        ([], True, {"intA", "intB", "enrichIndicators"}),  # no brands, external → INTERNAL + EXTERNAL
        (["A"], False, {"intA", "enrichIndicators"}),  # brand A, no external → INTERNAL(A)
        (["B"], True, {"intB", "enrichIndicators"}),  # brand B, no external → INTERNAL(B)
        (["X"], False, {"enrichIndicators"}),  # brand not matching INTERNAL → EXTERNAL only
        (["A", "B"], False, {"intA", "intB", "enrichIndicators"}),  # both INTERNAL brands
    ],
)
def test_prepare_commands_various(module_factory, requested_brands, external_enrichment, expected_names):
    """
    Given:
        - A boolean flag indicating whether external enrichment is enabled.
        - Different commands for different brands.
    When:
        - Calling `prepare_commands`.
    Then:
        - If no brands are requested all internal commands are returned.
        - If no brands and external_enrichment=true all commands return.
        - If brands are requested, only the requested internal commands are returned + external commands (e.g., enrichIndicators).
    """
    indicator = IndicatorSchema(type="url", value_field="Data", context_path_prefix="URL(", context_output_mapping={})
    cmd_intA = Command(name="intA", args={}, brand="A", command_type=CommandType.INTERNAL)
    cmd_intB = Command(name="intB", args={}, brand="B", command_type=CommandType.INTERNAL)
    cmd_ext = Command(name="enrichIndicators", args={"indicatorsValues": "example.com"}, command_type=CommandType.EXTERNAL)

    all_commands = [cmd_intA, cmd_intB, cmd_ext]
    module = module_factory(brands=requested_brands, indicator_schema=indicator, commands=[all_commands])

    batches = module.prepare_commands_batches(external_enrichment=external_enrichment)
    flattened = [c for batch in batches for c in batch]

    assert {c.name for c in flattened} == expected_names


def test_prepare_commands_includes_builtin(module_factory):
    """
    Given:
        - A builtin command.
    When:
        - Calling `prepare_commands_batches`.
    Then:
        - The builtin command is included in the first batch.
    """
    cmd_bi = Command(name="createNewIndicator", command_type=CommandType.BUILTIN)
    module = module_factory(commands=[[cmd_bi]], brands=["Whatever"], indicator_schema=IndicatorSchema("url", "Data", "URL(", {}))
    batches = module.prepare_commands_batches(external_enrichment=False)
    assert any(c.name == "createNewIndicator" for c in batches[0])


@pytest.mark.parametrize(
    "result_tuple, mock_mapped_context_return, expected_entry_status, expected_entry_msg",
    [
        (  # Success
            (
                {"EntryContext": {"URL": {"Data": "a.com"}}},
                "Human Readable for success",
                "",
            ),
            {"URL": "a.com"},
            Status.SUCCESS,
            "",
        ),
        (  # Success without context
            ({}, "", ""),
            {},
            Status.SUCCESS,
            "No matching indicators found.",
        ),
        (  # Failure with partial context
            (
                {"EntryContext": {}},
                "Human Readable for error",
                "Command failed",
            ),
            {"Partial": "Data"},
            Status.FAILURE,
            "Command failed",
        ),
        (  # Failure without context
            ({}, "", "error"),
            {},
            Status.FAILURE,
            "error",
        ),
    ],
)
def test_process_single_command_result(
    mocker,
    module_factory,
    result_tuple,
    mock_mapped_context_return,
    expected_entry_status,
    expected_entry_msg,
):
    """
    Given:
        - A result tuple (raw_result, hr_output, error) and a Command object.
    When:
        - Calling _process_single_command_result.
    Then:
        - Ensure the returned tuple of (entry, mapped_context, verbose_output) is correct.
        - The EntryResult status and message should reflect the error state and presence of context.
        - The mapped_context should be correctly extracted from the raw result.
        - The verbose_output should depend on the 'verbose' instance flag.
    """
    module = module_factory()
    mocker.patch.object(module, "map_command_context", return_value=mock_mapped_context_return)
    raw_result, _, _ = result_tuple

    command_obj = Command(name="test-cmd", brand="TestBrand", args={})
    entry, mapped_ctx, verbose_out = module._process_single_command_result(result_tuple, command_obj)

    # Assert
    # --- Assert EntryResult ---
    assert isinstance(entry, EntryResult)
    assert entry.status == expected_entry_status
    assert entry.message == expected_entry_msg
    assert entry.command_name == command_obj.name
    assert entry.brand == command_obj.brand

    # --- Assert Other Return Values ---
    if raw_result.get("EntryContext"):
        assert mapped_ctx == mock_mapped_context_return
    else:
        assert mapped_ctx == {}


# -- TIM Logic --
@pytest.mark.parametrize(
    "search_return_value",
    [
        ([]),  # Standard case: Search returns empty list
        (None),  # Edge case: Search returns None
    ],
)
def test_get_indicators_from_tim_early_return(module_factory, mocker, search_return_value):
    """
    Given:
        - search_indicators_in_tim returns an empty list or None.
    When:
        - Calling get_indicators_from_tim.
    Then:
        - process_tim_results should NOT be called.
        - The method should return immediately.
    """
    mod = module_factory(indicator_schema=default_indicator)

    mocker.patch.object(mod, "search_indicators_in_tim", return_value=search_return_value)

    proc = mocker.patch.object(mod, "process_tim_results")

    mod.get_indicators_from_tim()
    proc.assert_not_called()


def test_get_indicators_from_tim_success_passthrough(module_factory, mocker):
    """
    Given:
        - search_indicators_in_tim returns a non-empty list of IOCs.
    When:
        - Calling get_indicators_from_tim.
    Then:
        - The IOCs are passed directly to process_tim_results.
    """
    # 1. Setup
    iocs = [{"value": "https://a.com"}]
    mod = module_factory(indicator_schema=default_indicator)

    # NEW: search_indicators_in_tim returns just the list of IOCs now
    mocker.patch.object(mod, "search_indicators_in_tim", return_value=iocs)

    # NEW: process_tim_results is a void method (updates state via side effects)
    # We just need to mock it to assert it was called.
    proc = mocker.patch.object(mod, "process_tim_results")

    # 2. Execution
    mod.get_indicators_from_tim()

    # 3. Assertion
    # Verify the "Happy Path": Data flowed from Search -> Process
    proc.assert_called_once_with(iocs)


@pytest.mark.parametrize(
    "ioc_input, mock_tim_indicator_return, mock_parse_indicator_side_effect, expected_indicators, expected_entry_msg",
    [
        (
            build_ioc(
                value="ioc.example.com",
                score=2,
                scores={
                    "BrandA": {"score": 2, "context": {"data": "A"}, "Releiability": "High"},
                    "BrandB": {"score": 3, "context": {"data": "B"}, "Releiability": "Medium"},
                },
            ),
            {"Brand": "TIM", "Score": 2},
            {
                "BrandA": ([{"Value": "from_brand_a"}]),
                "BrandB": ([{"Value": "from_brand_b"}]),
            },
            [
                {"Brand": "TIM", "Score": 2, "Value": "ioc.example.com"},
                {"Value": "from_brand_a"},
                {"Value": "from_brand_b"},
            ],
            "Found indicator from brands: BrandA, BrandB.",
        ),
        (
            build_ioc(value="1.1.1.1", score=1, scores={}),
            {"Brand": "TIM", "Score": 1},
            {},
            [{"Brand": "TIM", "Score": 1, "Value": "1.1.1.1"}],
            "No matching indicators found.",
        ),
    ],
)
def test_process_single_tim_ioc(
    mocker,
    module_factory,
    ioc_input,
    mock_tim_indicator_return,
    mock_parse_indicator_side_effect,
    expected_indicators,
    expected_entry_msg,
):
    """
    Given:
        - An IOC dictionary representing a result from a TIM search.
    When:
        - Calling _process_single_tim_ioc with the IOC.
    Then:
        - Ensure the returned tuple is (parsed_indicators, value_string, message_string).
        - The indicators list should include results from both the main TIM object and brand-specific contexts.
        - The returned message should reflect which brands were processed.
    """
    # 1. Arrange
    module = module_factory()

    # The new code does: value = tim_indicator.get("Value", "")
    # We must ensure the mock return dict has the 'Value' so the assertion matches the input.
    if "Value" not in mock_tim_indicator_return:
        mock_tim_indicator_return["Value"] = ioc_input.get("value")

    mocker.patch.object(module, "create_tim_indicator", return_value=(mock_tim_indicator_return, 0))

    def side_effect(entry_context, brand, reliability, score):
        return mock_parse_indicator_side_effect.get(brand, [])

    mocker.patch.object(module, "parse_indicator", side_effect=side_effect)

    # 2. Act
    # NEW Signature: returns (list[dict], str, str)
    indicators, score, value, message = module._process_single_tim_ioc(ioc_input)

    # 3. Assert
    assert indicators == expected_indicators
    # Verify values returned directly in the tuple
    assert value == ioc_input.get("value")
    assert message == expected_entry_msg


def test_search_indicators_in_tim_exception_path(module_factory, mocker):
    """
    Given:
        - IndicatorsSearcher raises an exception during construction/iteration.
    When:
        - Calling search_indicators_in_tim.
    Then:
        - Returns an empty list [].
        - Updates the relevant indicator_instances with the error message.
    """
    # 1. Setup
    # We must provide instances so the code has something to update with the error
    instance = IndicatorInstance(raw_input="example.com", extracted_value="example.com")
    mod = module_factory(indicator_instances=[instance])

    # 2. Mock Exception
    mocker.patch(
        "AggregatedCommandApiModule.IndicatorsSearcher",
        side_effect=Exception("Failed to search TIM"),
    )

    # 3. Execution
    iocs = mod.search_indicators_in_tim()

    # 4. Assertions
    # New code returns empty list on exception
    assert iocs == []

    # New code updates the instance state with the error
    assert instance.hr_message is not None
    assert "Failed to search TIM" in instance.hr_message


@pytest.mark.parametrize(
    "data, pages, expected_iocs",
    [
        # Scenario 1: Search succeeds but finds no matching indicators
        (
            ["a.com", "b.com"],
            [{"iocs": []}],  # iterable yields one page with no iocs
            [],
        ),
        # Scenario 2: Search succeeds and finds indicators (across multiple pages)
        (
            ["a.com", "b.com"],
            [{"iocs": [{"value": "a.com"}]}, {"iocs": [{"value": "b.com"}]}],
            [{"value": "a.com"}, {"value": "b.com"}],
        ),
    ],
)
def test_search_indicators_in_tim_success(module_factory, mocker, data, pages, expected_iocs):
    """
    Given:
        - A list of indicator instances to search for.
    When:
        - Calling the search_indicators_in_tim method.
    Then:
        - It constructs IndicatorsSearcher with the correct query.
        - It flattens 'iocs' from pages and returns just the list of IOCs.
    """
    instances = [IndicatorInstance(raw_input=val, extracted_value=val) for val in data]
    schema = IndicatorSchema(type="URL", value_field="Value", context_path_prefix="URL(", context_output_mapping={})

    mod = module_factory(indicator_instances=instances, indicator_schema=schema)

    captured = {}

    def _searcher_ctor(*args, **kwargs):
        captured["query"] = kwargs.get("query")

        class _Searcher:
            def __iter__(self):
                return iter(pages)

        return _Searcher()

    searcher_mock = mocker.patch(
        "AggregatedCommandApiModule.IndicatorsSearcher",
        side_effect=_searcher_ctor,
    )

    iocs = mod.search_indicators_in_tim()

    assert searcher_mock.call_count == 1

    q = captured.get("query", "")
    assert f"type:{schema.type}" in q
    assert "value:(" in q
    for val in data:
        assert f'"{val}"' in q

    assert iocs == expected_iocs


def test_create_tim_indicator_uses_score_and_status(module_factory, mocker):
    """
    Given:
        - A TIM IOC dict that includes an overall 'score'.
        - get_indicator_status_from_ioc returns IndicatorStatus.FRESH.
    When:
        - Calling create_tim_indicator.
    Then:
        - Returns a dict with Brand='TIM', the provided Score, and Status from get_indicator_status_from_ioc.
    """
    mod = module_factory()
    ioc = {"score": 2, "value": "indicator1"}

    status_mock = mocker.patch.object(mod, "get_indicator_status_from_ioc", return_value=IndicatorStatus.FRESH.value)

    res, indicator_score = mod.create_tim_indicator(ioc)

    status_mock.assert_called_once_with(ioc)
    assert res["Status"] == IndicatorStatus.FRESH.value
    assert res["Brand"] == "TIM"
    assert res["Score"] == 2
    assert res[default_indicator.value_field] == "indicator1"
    assert res["ModifiedTime"] is None
    assert indicator_score == 2


def test_create_tim_indicator_file_type_maps_value_using_hashes(module_factory, mocker):
    """
    Given:
        - A ReputationAggregatedCommand configured for type='file' with multiple hash fields.
        - self.valid_inputs contains the original input hash (MD5) derived from indicator_instances.
        - The TIM IOC 'value' is SHA256, and CustomFields include both md5/sha256.
    When:
        - create_tim_indicator is called.
    Then:
        - The resulting TIM indicator:
            - Has Brand='TIM'.
            - Has MD5/SHA256 fields mapped from CustomFields.
            - Has Value equal to the original input hash (MD5), via map_back_to_input logic.
    """
    # 1. Define Schema
    file_indicator = IndicatorSchema(
        type="file",
        value_field=["MD5", "SHA256"],
        context_path_prefix="File(",
        context_output_mapping={
            "MD5": "MD5",
            "SHA256": "SHA256",
            "Score": "Score",
        },
    )

    original_md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    sha256_val = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

    # 2. Setup Instance (New Architecture)
    # The module extracts 'valid_inputs' from these instances
    instance = IndicatorInstance(raw_input=original_md5, extracted_value=original_md5)

    # 3. Initialize Module
    mod = module_factory(
        indicator_schema=file_indicator,
        indicator_instances=[instance],
    )

    # 4. Input IOC from TIM (Main value is SHA256)
    ioc = {
        "score": 3,
        "value": sha256_val,
        "modifiedTime": "2025-09-01T00:00:00Z",
        "CustomFields": {
            "md5": original_md5,
            "sha256": sha256_val,
        },
    }

    # 5. Mock Status Helper
    status_mock = mocker.patch.object(
        mod,
        "get_indicator_status_from_ioc",
        return_value=IndicatorStatus.FRESH.value,
    )

    # 6. Execute
    res, _ = mod.create_tim_indicator(ioc)

    # 7. Assertions
    status_mock.assert_called_once_with(ioc)

    assert res["Brand"] == "TIM"
    assert res["Score"] == 3
    assert res["Status"] == IndicatorStatus.FRESH.value
    assert res["ModifiedTime"] == "2025-09-01T00:00:00Z"

    # Hash fields mapped from CustomFields
    assert res["MD5"] == original_md5
    assert res["SHA256"] == sha256_val

    # For file indicators, Value is mapped back to the original input (MD5)
    # instead of the TIM 'value' (SHA256)
    assert res["Value"] == original_md5


@pytest.mark.parametrize(
    "has_manual, modified_mode, expected_status",
    [
        # Manual overrides anything (even recent modification)
        (True, "fresh", IndicatorStatus.MANUAL.value),
        # Fresh if modified within STATUS_FRESHNESS_WINDOW
        (False, "fresh", IndicatorStatus.FRESH.value),
        # Stale if modified long ago
        (False, "stale", IndicatorStatus.STALE.value),
        # Invalid timestamp string → None
        (False, "invalid", None),
        # No modifiedTime and not manual → None
        (False, "none", None),
    ],
)
def test_get_indicator_status_from_ioc_various(module_factory, has_manual, modified_mode, expected_status):
    """
    Given:
        - Various combinations of 'manuallyEditedFields' and 'modifiedTime'.
    When:
        - Calling get_indicator_status_from_ioc.
    Then:
        - Returns MANUAL if 'Score' in manuallyEditedFields.
        - Else FRESH if modifiedTime within freshness window.
        - Else STALE (including invalid/no modifiedTime).
    """
    mod = module_factory()
    now = datetime.now(UTC)

    def iso(dt: datetime) -> str:
        # Code under test accepts 'Z' or '+00:00'; it replaces Z → +00:00, so we emit 'Z' here.
        return dt.isoformat().replace("+00:00", "Z")

    ioc = {}
    if has_manual:
        ioc["manuallyEditedFields"] = {"Score": True}

    if modified_mode == "fresh":
        ioc["modifiedTime"] = iso(now - timedelta(days=1))  # well within 1 week
    elif modified_mode == "stale":
        ioc["modifiedTime"] = iso(now - timedelta(days=30))  # far beyond 1 week
    elif modified_mode == "invalid":
        ioc["modifiedTime"] = "not-a-time"
    elif modified_mode == "none":
        # leave modifiedTime absent
        pass

    status = mod.get_indicator_status_from_ioc(ioc)
    assert status == expected_status


def test_get_indicator_status_from_ioc_boundary_freshness_window(module_factory):
    """
    Given:
        - modifiedTime just inside the STATUS_FRESHNESS_WINDOW.
    When:
        - Calling get_indicator_status_from_ioc.
    Then:
        - Returns FRESH at the boundary (minus 1 second).
    """
    mod = module_factory()
    now = datetime.now(UTC)
    boundary_time = now - STATUS_FRESHNESS_WINDOW + timedelta(hours=1)

    ioc = {"modifiedTime": boundary_time.isoformat().replace("+00:00", "Z")}
    assert mod.get_indicator_status_from_ioc(ioc) == IndicatorStatus.FRESH.value


def test_get_indicator_status_from_ioc_boundary_stale(module_factory):
    """
    Given:
        - modifiedTime just outside the STATUS_FRESHNESS_WINDOW.
    When:
        - Calling get_indicator_status_from_ioc.
    Then:
        - Returns STALE at the boundary (plus 1 second).
    """
    mod = module_factory()
    now = datetime.now(UTC)
    boundary_time = now - STATUS_FRESHNESS_WINDOW - timedelta(seconds=1)

    ioc = {"modifiedTime": boundary_time.isoformat().replace("+00:00", "Z")}
    assert mod.get_indicator_status_from_ioc(ioc) == IndicatorStatus.STALE.value


def test_update_indicator_instances_status_logic(module_factory):
    """
    Given:
        - A module with indicator instances.
        - EntryResults simulating command executions:
            1. CreateNewIndicatorsOnly -> Success
            2. enrichIndicators -> Failure
    When:
        - update_indicator_instances_status is called.
    Then:
        - The instances should be updated:
            - created = True
            - enriched = False
            - error_message should contain the enrichment error.
            - final_status should be FAILURE (because of error).
    """
    # 1. Setup
    instance = IndicatorInstance(raw_input="8.8.8.8", extracted_value="8.8.8.8")
    mod = module_factory(indicator_instances=[instance])

    # Simulate command results already stored in the module
    mod.entry_results = [
        EntryResult(command_name="CreateNewIndicatorsOnly", brand="Builtin", status=Status.SUCCESS, message="Created", args={}),
        EntryResult(command_name="enrichIndicators", brand="External", status=Status.FAILURE, message="Quota exceeded", args={}),
    ]

    # 2. Execute
    mod.update_indicator_instances_status()

    # 3. Assertions
    assert instance.created is True
    assert instance.enriched is False
    assert instance.error_message
    assert "Quota exceeded" in instance.error_message
    assert instance.final_status == Status.FAILURE


@pytest.mark.parametrize(
    "schema_type, indicator_score, expected_field_name",
    [
        ("ip", 2, "TIM Score"),
        ("cve", 7.1, "TIM CVSS"),
    ],
)
def test_create_indicators_entry_results_sets_score_fields(module_factory, schema_type, indicator_score, expected_field_name):
    """
    Given:
        - IndicatorInstances with specific final statuses.
        - Existing command entry results.
    When:
        - create_indicators_entry_results is called.
    Then:
        - New EntryResult objects (Brand='TIM') are PREPENDED to the list.
    """
    # 1. Setup
    instance = IndicatorInstance(
        raw_input="v",
        extracted_value="v",
        final_status=Status.SUCCESS,
        hr_message="Found",
        indicator_score=indicator_score,
    )
    schema = IndicatorSchema(type=schema_type, value_field="Value", context_path_prefix="X(", context_output_mapping={})
    mod = module_factory(indicator_schema=schema, indicator_instances=[instance])

    # Existing entry (e.g., from an enrichment command)
    existing_entry = EntryResult("cmd", {}, "brand", Status.SUCCESS, "")
    mod.entry_results = [existing_entry]

    # 2. Execute
    mod.create_indicators_entry_results()

    # 3. Assertions
    assert len(mod.entry_results) == 2

    # The TIM entry should be first
    tim_entry = mod.entry_results[0]
    assert tim_entry.brand == "TIM"
    assert tim_entry.status == Status.SUCCESS
    assert tim_entry.message == "Found"
    assert tim_entry.score == indicator_score

    # The existing entry remains
    assert mod.entry_results[1] == existing_entry


@pytest.mark.parametrize(
    "entries, expected_is_error",
    [
        # 1. All failed -> Error
        (
            [
                make_entry_result("c1", "A", Status.FAILURE, "Error"),
                make_entry_result("c2", "B", Status.FAILURE, "Error"),
            ],
            True,
        ),
        # 2. Mix of failures + 'No matching...' (soft failure) -> Error (because no actual success occurred)
        (
            [
                make_entry_result("c1", "A", Status.FAILURE, "Error"),
                make_entry_result("c2", "B", Status.SUCCESS, "No matching indicators found."),
            ],
            True,
        ),
        # 3. At least one real success (Status.SUCCESS + empty/valid message) -> Success
        (
            [
                make_entry_result("c1", "A", Status.SUCCESS, ""),
                make_entry_result("c2", "B", Status.FAILURE, "Error"),
            ],
            False,
        ),
        # 4. Single real success -> Success
        (
            [
                make_entry_result("c1", "A", Status.SUCCESS, ""),
            ],
            False,
        ),
        # 5. Only 'No matching indicators found' -> Success (valid state, not system error)
        (
            [
                make_entry_result("c1", "A", Status.SUCCESS, "No matching indicators found."),
                make_entry_result("c2", "B", Status.SUCCESS, "No matching indicators found."),
            ],
            False,
        ),
    ],
)
def test_is_final_result_error(module_factory, entries, expected_is_error):
    """
    Given:
        - A list of entries with different statuses and messages.
    When:
        - Calling _is_error_result.
    Then:
        - Returns True only when there was at least one FAILURE and
          all entries are either FAILURE or 'No matching indicators found.'.
    """
    mod = module_factory()

    assert mod._is_final_result_error(entries) == expected_is_error


def test_reputation_aggregated_command_redundant_error_raising_true(module_factory):
    """
    Given:
        - A ReputationAggregatedCommand instance created with redundant_error_raising=True.
        - A mix of entries: one FAILURE and one SUCCESS with the message "No matching indicators found.".
    When:
        - Calling _is_final_result_error.
    Then:
        - Returns False, because when redundant_error_raising is True the soft-failure
          message "No matching indicators found." is NOT treated as a failure, so not
          all entries are failures.
    """
    mod = module_factory(redundant_error_raising=True)

    entries = [
        make_entry_result("c1", "A", Status.FAILURE, "Error"),
        make_entry_result("c2", "B", Status.SUCCESS, "No matching indicators found."),
    ]

    # Sanity: the flag is stored on the instance and defaults to False elsewhere.
    assert mod.redundant_error_raising is True
    # Behavior: with the flag on, a mixed result is NOT considered a final error.
    assert mod._is_final_result_error(entries) is False
    # And when ALL entries are real failures, it should still be considered an error.
    all_failures = [
        make_entry_result("c1", "A", Status.FAILURE, "Error"),
        make_entry_result("c2", "B", Status.FAILURE, "Error"),
    ]
    assert mod._is_final_result_error(all_failures) is True


def test_reputation_aggregated_command_redundant_error_raising_false(module_factory):
    """
    Given:
        - A ReputationAggregatedCommand instance created without specifying the flag
          (so redundant_error_raising defaults to False).
        - A mix of entries: one FAILURE and one SUCCESS with the message
          "No matching indicators found.".
    When:
        - Calling _is_final_result_error.
    Then:
        - Returns True, preserving the legacy behavior where "No matching indicators found."
          is treated as a soft failure and a mixed result is reported as an error.
    """
    mod = module_factory()

    entries = [
        make_entry_result("c1", "A", Status.FAILURE, "Error"),
        make_entry_result("c2", "B", Status.SUCCESS, "No matching indicators found."),
    ]

    # Sanity: the flag defaults to False.
    assert mod.redundant_error_raising is False
    # Behavior: with the flag off (legacy), a mixed result IS considered a final error.
    assert mod._is_final_result_error(entries) is True


def test_summarize_command_results_uses_is_error_result_for_entry_type(module_factory, mocker):
    """
    Given:
        - A module instance with some entry_results.
    When:
        - summarize_command_results is called.
    Then:
        - A DemistoException is raised iff _is_final_result_error returns True.
        - Otherwise a success CommandResults (non-error entry type) is returned.
    """
    mod = module_factory()
    # Don't let these mutate entry_results, we want to control it
    mocker.patch.object(mod, "create_indicators_entry_results")
    mocker.patch.object(mod, "_inject_unsupported_brands")

    # Avoid depending on markdown formatting
    mocker.patch("AggregatedCommandApiModule.tableToMarkdown", return_value="TBL")

    # Case 1: _is_final_result_error -> True => raises DemistoException
    mocker.patch.object(mod, "_is_final_result_error", return_value=True)
    mod.entry_results = [make_entry_result("c1", "A", Status.FAILURE, "Error")]
    with pytest.raises(DemistoException, match=r"Error: All commands failed or no indicators found\."):
        mod.summarize_command_results(final_context={"ctx": 1})

    # Case 2: _is_final_result_error -> False => success (default entry type)
    mocker.patch.object(mod, "_is_final_result_error", return_value=False)
    mod.entry_results = [make_entry_result("c2", "B", Status.SUCCESS, "")]
    res = mod.summarize_command_results(final_context={"ctx": 1})
    assert res.entry_type != entryTypes["error"]


def test_inject_unsupported_brands_adds_failure_entries(module_factory, mocker):
    """
    Given:
        - Unsupported enrichment brands exist ('X', 'Y').
    When:
        - _inject_unsupported_brands is called.
    Then:
        - EntryResult objects are added for each unsupported brand as failures
          with the 'Unsupported Command' message.
    """
    mod = module_factory(brands=["X", "Y"])

    # Make BrandManager return our fake unsupported brands when property is evaluated
    mod.brand_manager.unsupported_external = lambda _commands: ["X", "Y"]

    # Assume we already have one valid entry
    mod.entry_results = [make_entry_result("c1", "A", Status.SUCCESS, "")]

    # Act
    mod._inject_unsupported_brands()

    # Assert: we now have 3 entries: original + X + Y
    assert len(mod.entry_results) == 3

    by_brand = {e.brand: e for e in mod.entry_results}
    for brand in ("X", "Y"):
        assert brand in by_brand
        unsupported = by_brand[brand]
        assert unsupported.status == Status.FAILURE
        assert "Unsupported Command" in (unsupported.message or "")


def test_build_final_human_readable_includes_unsupported_brands(module_factory, mocker):
    """
    Given:
        - EntryResults including unsupported enrichment brands.
    When:
        - _build_final_human_readable is called.
    Then:
        - The table rows include one entry per unsupported brand with failure status.
    """
    mod = module_factory()

    # We simulate after _inject_unsupported_brands already ran:
    mod.entry_results = [
        make_entry_result("c1", "A", Status.SUCCESS, ""),
        make_entry_result(
            "c2", "X", Status.FAILURE, "Unsupported Command: Verify you have proper integrations enabled to support it"
        ),
        make_entry_result(
            "c3", "Y", Status.FAILURE, "Unsupported Command: Verify you have proper integrations enabled to support it"
        ),
    ]

    tbl = mocker.patch("AggregatedCommandApiModule.tableToMarkdown", return_value="TBL")

    # Only brandful entries are passed into the table
    final_entries = [e for e in mod.entry_results if e.brand]
    _ = mod._build_final_human_readable(final_entries)

    args, kwargs = tbl.call_args
    table_rows = kwargs.get("t", args[1] if len(args) > 1 else None)

    for brand in ("X", "Y"):
        row = next(r for r in table_rows if r.get("Brand") == brand)
        assert row.get("Status") == Status.FAILURE.value
        assert "Unsupported Command" in (row.get("Message") or "")


@pytest.mark.parametrize(
    "schema_type, expected_header",
    [
        ("ip", "TIM Score"),
        ("cve", "TIM CVSS"),
    ],
)
def test_build_final_human_readable_uses_dynamic_score_header(module_factory, mocker, schema_type, expected_header):
    """
    Given:
        - A module configured with an IndicatorSchema of a specific type:
            * type="ip"   -> should render "TIM Score" column in the HR table headers.
            * type="cve"  -> should render "TIM CVSS" column in the HR table headers.
        - A TIM EntryResult with score_field_name aligned to the expected header.

    When:
        - Calling _build_final_human_readable with the TIM entry.

    Then:
        - tableToMarkdown is called with headers that include the expected dynamic score header
          ("TIM Score" for non-CVE, "TIM CVSS" for CVE).
    """
    schema = IndicatorSchema(type=schema_type, value_field="Value", context_path_prefix="X(", context_output_mapping={})
    mod = module_factory(indicator_schema=schema)

    tbl = mocker.patch("AggregatedCommandApiModule.tableToMarkdown", return_value="TBL")

    entries = [
        EntryResult(
            command_name="", args="v", brand="TIM", status=Status.SUCCESS, message="m", score_field_name=expected_header, score=5
        )
    ]
    _ = mod._build_final_human_readable(entries)

    _, kwargs = tbl.call_args
    headers = kwargs["headers"]
    assert expected_header in headers


def test_summarize_command_results_appends_unsupported_enrichment_row(module_factory, mocker):
    """
    Given:
        - Unsupported enrichment brands exist ('X','Y').
    When:
        - summarize_command_results is called.
    Then:
        - The HR table includes a row for each unsupported brand.
    """
    mod = module_factory(brands=["X", "Y"])
    # Make the property return our list via BrandManager
    mod.brand_manager.unsupported_external = lambda _commands: ["X", "Y"]

    tbl = mocker.patch("AggregatedCommandApiModule.tableToMarkdown", return_value="TBL")

    mod.entry_results = [make_entry_result("c1", "A", Status.SUCCESS, "")]
    res = mod.summarize_command_results(final_context={"ctx": 1})
    assert res.readable_output == "TBL"

    # Inspect the table rows passed to tableToMarkdown
    args, kwargs = tbl.call_args
    table_rows = kwargs.get("t", args[1] if len(args) > 1 else None)

    # There should be separate rows for X and Y, each marked as failure with the unsupported message
    for brand in ("X", "Y"):
        row = next(r for r in table_rows if r.get("Brand") == brand)
        assert row.get("Status") == Status.FAILURE.value
        assert "Unsupported Command" in (row.get("Message") or "")


# -- Context Mapping --
@pytest.mark.parametrize(
    "mapping, entry, expected",
    [
        # Empty entry
        ({"a..b": "x..y"}, {}, {}),
        # empty mapping
        ({}, {"a": {"b": 10}}, {"a": {"b": 10}}),
        # simple nested mapping
        ({"a..b": "x..y"}, {"a": {"b": 10}}, {"x": {"y": 10}}),
        # mapping to list via '[]'
        ({"a..b": "x..y[]"}, {"a": {"b": 5}}, {"x": {"y": [5]}}),
        # multiple mappings
        ({"a..b": "out..b", "c": "out..c"}, {"a": {"b": 1}, "c": 2}, {"out": {"b": 1, "c": 2}}),
        # mapping is None
        (None, {"a": {"b": 10}}, {}),
    ],
)
def test_map_command_context_basic(module_factory, mapping, entry, expected):
    """
    Given:
        - Various mappings and entry contexts.
    When:
        - Calling map_command_context.
    Then:
        - Returns a dict matching the expected mapped_context.
    """
    module = module_factory()
    # Copy entry so the original in test is not mutated
    entry_copy = {k: (v.copy() if isinstance(v, dict) else v) for k, v in entry.items()}
    result = module.map_command_context(entry_copy, mapping, is_indicator=False)
    assert result == expected


@pytest.mark.parametrize("val", [0, 0.0, "", False])
def test_map_command_context_preserves_falsy_values(module_factory, val):
    module = module_factory(additional_fields=False)
    mapping = {"a..b": "x..y"}
    entry = {"a": {"b": val}}
    out = module.map_command_context(entry, mapping, is_indicator=True)
    assert out == {"x": {"y": val}}


@pytest.mark.parametrize(
    "mapping, entry, is_indicator, additional_fields, expected",
    [
        # is_indicator False even if additional_fields True → no AdditionalFields
        ({"x": "y"}, {"x": 1, "z": 2}, False, True, {"y": 1}),
        # is_indicator True even if additional_fields False → no AdditionalFields
        ({"x": "y"}, {"x": 1, "z": 2}, True, False, {"y": 1}),
        # is_indicator True and additional_fields True → AdditionalFields full
        ({"x": "y"}, {"x": 1, "z": 2}, True, True, {"y": 1, "AdditionalFields": {"z": 2}}),
        # mapping consumes all keys → AdditionalFields empty dict
        ({"m": "n"}, {"m": 5}, True, True, {"n": 5}),
    ],
)
def test_map_command_context_indicator_flag(module_factory, mapping, entry, is_indicator, additional_fields, expected):
    """
    Given:
        - Mapping and entry that may or may not leave leftovers.
        - Flags is_indicator and additional_fields combinations.
    When:
        - Calling map_command_context.
    Then:
        - AdditionalFields only appears when both flags are True.
    """
    module = module_factory(additional_fields=additional_fields)
    entry_copy = {k: (v.copy() if isinstance(v, dict) else v) for k, v in entry.items()}
    result = module.map_command_context(entry_copy, mapping, is_indicator=is_indicator)

    for k, v in expected.items():
        assert result.get(k) == v

    if "AdditionalFields" in expected:
        assert "AdditionalFields" in result
        assert result["AdditionalFields"] == expected["AdditionalFields"]
    else:
        assert "AdditionalFields" not in result


# -------------------------------------------------------------------------------------------------
# -- Tests for create_and_extract_indicators_batch
# -------------------------------------------------------------------------------------------------


def test_create_and_extract_indicators_batch_empty_data():
    """
    Given:
        - An empty data list.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Returns an empty list of IndicatorInstance objects and a non-empty HR string,
          without calling extractIndicators.
    """
    instances, hr = create_and_extract_indicators_batch([], "IP")
    assert instances == []
    assert isinstance(hr, str)
    assert "extractIndicators" in hr


def test_create_and_extract_indicators_batch_valid_ips(mocker):
    """
    Given:
        - A list of valid IP addresses.
    When:
        - Calling create_and_extract_indicators_batch with indicator_type="IP".
    Then:
        - Returns one IndicatorInstance per extracted IP, with raw_input == extracted_value
          and the default SUCCESS state, plus a non-empty HR string.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": {"IP": ["1.1.1.1", "8.8.8.8"]}}}],
    )
    instances, hr = create_and_extract_indicators_batch(["1.1.1.1", "8.8.8.8"], "IP")

    assert len(instances) == 2
    assert all(isinstance(i, IndicatorInstance) for i in instances)
    assert [i.extracted_value for i in instances] == ["1.1.1.1", "8.8.8.8"]
    assert [i.raw_input for i in instances] == ["1.1.1.1", "8.8.8.8"]
    # Default state for valid extracted indicators
    assert all(i.final_status == Status.SUCCESS for i in instances)
    assert all(i.created is False for i in instances)
    assert all(i.enriched is False for i in instances)
    assert all(i.tim_context is None for i in instances)
    assert isinstance(hr, str)


def test_create_and_extract_indicators_batch_no_matching_type(mocker):
    """
    Given:
        - extractIndicators returns indicators but none match the requested type.
    When:
        - Calling create_and_extract_indicators_batch with indicator_type="Domain".
    Then:
        - Returns an empty list of IndicatorInstance objects (no matching type found).
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": {"IP": ["1.1.1.1"]}}}],
    )
    instances, hr = create_and_extract_indicators_batch(["1.1.1.1"], "Domain")
    assert instances == []
    assert isinstance(hr, str)
    assert hr


def test_create_and_extract_indicators_batch_extract_fails(mocker):
    """
    Given:
        - execute_command('extractIndicators', ...) returns None/empty.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Returns an empty list of IndicatorInstance objects (no exception raised) and an HR.
    """
    mocker.patch("AggregatedCommandApiModule.execute_command", return_value=None)

    instances, hr = create_and_extract_indicators_batch(["1.1.1.1"], "IP")
    assert instances == []
    assert isinstance(hr, str)
    assert hr


def test_create_and_extract_indicators_batch_extract_exception(mocker):
    """
    Given:
        - execute_command('extractIndicators', ...) raises an exception.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Raises DemistoException whose message includes both the validation failure
          prefix and the original underlying error message.
    """
    mocker.patch("AggregatedCommandApiModule.execute_command", side_effect=Exception("connection error"))

    with pytest.raises(
        DemistoException,
        match=r"Failed to validate input using extractIndicators: connection error",
    ):
        create_and_extract_indicators_batch(["1.1.1.1"], "IP")


def test_create_and_extract_indicators_batch_case_insensitive_type_key(mocker):
    """
    Given:
        - extractIndicators returns the matched type under a key with different casing
          than the requested indicator_type (e.g. "ip" vs "IP").
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - The matching is case-insensitive and IndicatorInstance objects are returned for
          every value under that key.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": {"ip": ["1.1.1.1", "8.8.8.8"]}}}],
    )
    instances, _ = create_and_extract_indicators_batch(["1.1.1.1", "8.8.8.8"], "IP")

    assert len(instances) == 2
    assert all(isinstance(i, IndicatorInstance) for i in instances)
    assert {i.extracted_value for i in instances} == {"1.1.1.1", "8.8.8.8"}


def test_create_and_extract_indicators_batch_empty_extracted_ctx(mocker):
    """
    Given:
        - extractIndicators returns a result whose ExtractedIndicators dict is empty.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Returns an empty list of IndicatorInstance objects and a non-empty HR string,
          taking the new "no matched indicators" early-return path without raising.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": {}}}],
    )
    instances, hr = create_and_extract_indicators_batch(["1.1.1.1"], "IP")

    assert instances == []
    assert isinstance(hr, str)
    assert "extractIndicators" in hr


def test_create_and_extract_indicators_batch_missing_extracted_indicators_key(mocker):
    """
    Given:
        - extractIndicators returns a result whose EntryContext does not contain the
          "ExtractedIndicators" key at all.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Returns an empty list of IndicatorInstance objects and a non-empty HR string
          (defensive `or {}` fallback path).
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {}}],
    )
    instances, hr = create_and_extract_indicators_batch(["1.1.1.1"], "IP")

    assert instances == []
    assert isinstance(hr, str)
    assert "extractIndicators" in hr


def test_create_and_extract_indicators_batch_no_matching_type_logs_debug(mocker):
    """
    Given:
        - extractIndicators returns indicators only for a non-requested type.
    When:
        - Calling create_and_extract_indicators_batch with a different indicator_type.
    Then:
        - The new "No valid indicators returned" debug log is emitted on the early-return
          path, an empty list is returned, and the HR contains the extracted-indicators
          table built from extracted_ctx (which is non-empty).
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[{"EntryContext": {"ExtractedIndicators": {"IP": ["1.1.1.1"]}}}],
    )
    debug_mock = mocker.patch.object(demisto, "debug")

    instances, hr = create_and_extract_indicators_batch(["1.1.1.1"], "Domain")

    assert instances == []
    assert isinstance(hr, str)
    # The HR should still include the table built from extracted_ctx
    assert "Extracted Indicators" in hr
    assert "1.1.1.1" in hr
    # The new early-return debug log should have been emitted
    debug_messages = [call.args[0] for call in debug_mock.call_args_list if call.args]
    assert any("No valid indicators returned" in msg for msg in debug_messages)


def test_create_and_extract_indicators_batch_ignores_other_types(mocker):
    """
    Given:
        - extractIndicators returns multiple indicator types but we request only one.
    When:
        - Calling create_and_extract_indicators_batch.
    Then:
        - Only IndicatorInstances for the requested type are returned.
    """
    mocker.patch(
        "AggregatedCommandApiModule.execute_command",
        return_value=[
            {
                "EntryContext": {
                    "ExtractedIndicators": {
                        "IP": ["1.1.1.1"],
                        "URL": ["https://example.com"],
                        "Domain": ["example.com"],
                    }
                }
            }
        ],
    )
    instances, _ = create_and_extract_indicators_batch(["1.1.1.1", "https://example.com"], "URL")

    assert len(instances) == 1
    assert isinstance(instances[0], IndicatorInstance)
    assert instances[0].extracted_value == "https://example.com"


def _stub_single_tim_ioc(mocker, module, returned_value: str):
    """
    Patch _process_single_tim_ioc to deterministically return one parsed indicator with a known
    value, score and message. The `returned_value` controls the key used to look up instances
    in `indicator_mapping` inside process_tim_results (it is `.lower()`-ed before lookup).
    """
    parsed_indicators = [{"Value": returned_value, "Brand": "TIM", "Score": 2}]
    score = 2
    message = "Found indicator from brands: TIM."
    mocker.patch.object(
        module,
        "_process_single_tim_ioc",
        return_value=(parsed_indicators, score, returned_value, message),
    )
    return parsed_indicators, score, message


def test_process_tim_results_handles_case_insensitive_duplicates_in_same_call(module_factory, mocker):
    """
    Given:
        - Two IndicatorInstance objects whose extracted_value differ only in case
          ("Test1.com" and "test1.com"), built into the same ReputationAggregatedCommand call.
        - TIM returns a single IOC for the lower-cased value "test1.com".
    When:
        - process_tim_results is invoked with that single IOC.
    Then:
        - BOTH instances must have tim_context populated.
        - BOTH must have indicator_score set.
        - BOTH must have hr_message set (no "Failed to extract from TIM" failure path triggered).
        - compute_status() afterwards must mark both as Status.SUCCESS (neither falls into the
          missing-tim_context failure path).
    """
    inst_upper = IndicatorInstance(raw_input="Test1.com", extracted_value="Test1.com", enriched=True, created=True)
    inst_lower = IndicatorInstance(raw_input="test1.com", extracted_value="test1.com", enriched=True, created=True)

    module = module_factory(indicator_instances=[inst_upper, inst_lower])

    # Sanity: the mapping must keep BOTH instances under the same lower-cased key.
    bucket = module.indicator_mapping["test1.com"]
    assert isinstance(bucket, list)
    assert bucket == [inst_upper, inst_lower]

    parsed_indicators, score, message = _stub_single_tim_ioc(mocker, module, returned_value="test1.com")

    module.process_tim_results([{"value": "test1.com"}])

    for inst in (inst_upper, inst_lower):
        assert inst.tim_context == parsed_indicators
        assert inst.indicator_score == score
        assert inst.hr_message == message

    # And neither should be flagged as the "TIM failure" once status is computed.
    for inst in (inst_upper, inst_lower):
        inst.compute_status()
        assert inst.final_status == Status.SUCCESS
        # The "Failed to extract from TIM using findIndicator." message comes from _build_failure_context
        # when tim_context is missing; ensure it did NOT slip into the context_message.
        assert "Failed to extract from TIM" not in (inst.context_message or "")


def test_process_tim_results_single_value_still_works(module_factory, mocker):
    """
    Given:
        - A single IndicatorInstance with extracted_value="test1.com" (lowercase).
        - TIM returns one IOC whose canonical value is "Test1.com" (different case).
    When:
        - process_tim_results is invoked.
    Then:
        - The single instance is enriched (tim_context populated, score and hr_message set).
        This guards the original regression fix (KeyError → safe case-insensitive lookup).
    """
    inst = IndicatorInstance(raw_input="test1.com", extracted_value="test1.com", enriched=True, created=True)
    module = module_factory(indicator_instances=[inst])

    assert module.indicator_mapping["test1.com"] == [inst]

    parsed_indicators, score, message = _stub_single_tim_ioc(mocker, module, returned_value="Test1.com")

    module.process_tim_results([{"value": "Test1.com"}])

    assert inst.tim_context == parsed_indicators
    assert inst.indicator_score == score
    assert inst.hr_message == message


def test_process_tim_results_three_way_case_variants(module_factory, mocker):
    """
    Given:
        - Three IndicatorInstance objects sharing the same lower-cased value but each with a
          distinct casing: "TEST1.com", "Test1.com", "test1.com".
        - TIM returns a single IOC for the value "Test1.com".
    When:
        - process_tim_results is invoked.
    Then:
        - All three instances receive tim_context, indicator_score and hr_message.
        - All three keep their original extracted_value casing (no case mutation).
    """
    inst_a = IndicatorInstance(raw_input="TEST1.com", extracted_value="TEST1.com", enriched=True, created=True)
    inst_b = IndicatorInstance(raw_input="Test1.com", extracted_value="Test1.com", enriched=True, created=True)
    inst_c = IndicatorInstance(raw_input="test1.com", extracted_value="test1.com", enriched=True, created=True)

    module = module_factory(indicator_instances=[inst_a, inst_b, inst_c])

    bucket = module.indicator_mapping["test1.com"]
    assert bucket == [inst_a, inst_b, inst_c]

    parsed_indicators, score, message = _stub_single_tim_ioc(mocker, module, returned_value="Test1.com")

    module.process_tim_results([{"value": "Test1.com"}])

    for inst in (inst_a, inst_b, inst_c):
        assert inst.tim_context == parsed_indicators
        assert inst.indicator_score == score
        assert inst.hr_message == message

    # Original casings must be preserved per instance.
    assert inst_a.extracted_value == "TEST1.com"
    assert inst_b.extracted_value == "Test1.com"
    assert inst_c.extracted_value == "test1.com"