"""
E2E tests for the ha_search 3-tier bulk fetch strategy.

Validates that the bulk config fetching logic (REST bulk -> WebSocket bulk ->
time-budgeted individual) works correctly and returns accurate search results
with config data populated.

These tests complement the existing test_deep_search.py tests, which focus on
single-entity search correctness. These tests specifically validate:
  - Bulk fetch populates config data across multiple automations/scripts
  - Config-only matches are found (content not in the name, only in config)
  - Search completes well within the MCP timeout window
  - Results include the config object (proving bulk fetch worked)
"""

import logging
import time
import uuid

import pytest

from ..utilities.assertions import assert_mcp_success

logger = logging.getLogger(__name__)

# Per-session unique marker so parallel workers never collide
_RUN_ID = uuid.uuid4().hex[:8]
_MARKER = f"bft{_RUN_ID}"

# Number of automations to create — enough to exercise the bulk path
# but not so many that setup/teardown dominates test time.
_AUTOMATION_COUNT = 10
_SCRIPT_COUNT = 5


def _automation_config(index: int) -> dict:
    """Build a test automation config with a unique, searchable token in the action."""
    return {
        "alias": f"{_MARKER} Automation {index}",
        "description": f"Test automation {index} for bulk fetch E2E validation",
        "trigger": [
            {
                "platform": "state",
                "entity_id": f"sensor.{_MARKER}_trigger_{index}",
                "to": "on",
            }
        ],
        "action": [
            {
                "service": "notify.persistent_notification",
                "data": {
                    "message": f"{_MARKER}_payload_{index}",
                    "title": f"Bulk test {index}",
                },
            }
        ],
    }


def _script_config(index: int) -> dict:
    """Build a test script config with a unique, searchable token in the sequence."""
    return {
        "alias": f"{_MARKER} Script {index}",
        "sequence": [
            {
                "service": "notify.persistent_notification",
                "data": {
                    "message": f"{_MARKER}_script_payload_{index}",
                },
            },
            {"delay": {"seconds": 1}},
        ],
    }


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
async def bulk_automations(mcp_client):
    """Create a batch of test automations once per module and tear them down after the module.

    Module-scoped because every test in this file is read-only against the
    created automations (search/list only; no modify/remove), so a single
    setup amortises ~20s of fixture overhead per consuming test (refs #366).
    """
    created_ids = []

    for i in range(_AUTOMATION_COUNT):
        # wait=False: skip the per-call registration poll and batch-verify all
        # registrations in one WS wait below. The documented bulk pattern, and
        # it avoids N sequential per-create registration waits (#1515).
        result = await mcp_client.call_tool(
            "ha_config_set_automation",
            {"config": _automation_config(i), "wait": False},
        )
        data = assert_mcp_success(result, f"Create bulk automation {i}")
        # Use the actual entity_id returned by HA (handles conflicts with _2 suffix)
        entity_id = data.get("entity_id")
        if not entity_id:
            # Fallback to predicted ID if not returned (shouldn't happen)
            entity_id = f"automation.{_MARKER}_automation_{i}"
            logger.warning(
                f"No entity_id returned for automation {i}, using predicted: {entity_id}"
            )
        created_ids.append(entity_id)
        logger.info(f"Created automation {i}/{_AUTOMATION_COUNT}: {entity_id}")

    # Confirm registration via HA WebSocket: the helper subscribes to
    # ``state_changed`` and takes a post-subscribe ``get_states`` sample, so
    # the entities created above (with wait=False) are confirmed in one batch
    # wait rather than N per-create polls.
    from ..utilities.wait_helpers import wait_for_entities_registered_via_ws

    seen = await wait_for_entities_registered_via_ws(created_ids, timeout=30.0)
    if seen != set(created_ids):
        # Fall back to a per-entity REST poll for the stragglers rather than
        # failing setup — keeps the fixture robust if the WS path drops
        # (e.g. a transient socket error under heavy CI load).
        from ..utilities.assertions import safe_call_tool
        from ..utilities.wait_helpers import wait_for_condition

        async def all_entities_registered():
            # Per-entity ha_get_state (there is no bulk "list states" tool);
            # an unregistered entity returns no data, so the poll retries.
            for eid in created_ids:
                data = await safe_call_tool(
                    mcp_client, "ha_get_state", {"entity_id": eid}
                )
                if not (isinstance(data, dict) and data.get("data") is not None):
                    return False
            return True

        registered = await wait_for_condition(
            all_entities_registered,
            condition_name=f"All {len(created_ids)} bulk automations registered (fallback)",
            timeout=10,
        )
        if not registered:
            # Fail loudly at setup rather than yielding unregistered entities,
            # which would surface as a confusing search-assertion failure
            # downstream instead of a clear "setup couldn't register" error.
            pytest.fail(
                f"bulk automations never registered after WS + REST waits "
                f"(WS saw {len(seen)}/{len(created_ids)})"
            )

    yield created_ids

    # Cleanup
    for auto_id in created_ids:
        try:
            await mcp_client.call_tool(
                "ha_config_remove_automation",
                {"identifier": auto_id},
            )
        except Exception as e:
            logger.warning(f"Cleanup failed for {auto_id}: {e}")
    logger.info(f"Cleaned up {len(created_ids)} bulk test automations")


@pytest.fixture(scope="module")
async def bulk_scripts(mcp_client):
    """Create a batch of test scripts once per module and tear them down after the module.

    Module-scoped for the same reason as ``bulk_automations`` above: every
    consuming test in this file is read-only against the created scripts.
    """
    created_ids = []

    for i in range(_SCRIPT_COUNT):
        script_id = f"{_MARKER}_script_{i}"
        # wait=False: batch-verify registrations in one WS wait below rather
        # than paying a per-create registration poll each iteration (#1515).
        result = await mcp_client.call_tool(
            "ha_config_set_script",
            {"script_id": script_id, "config": _script_config(i), "wait": False},
        )
        assert_mcp_success(result, f"Create bulk script {i}")
        created_ids.append(script_id)
        logger.info(f"Created script {i}/{_SCRIPT_COUNT}: {script_id}")

    # WS-based wait — mirrors the bulk_automations fixture above (subscribe +
    # post-subscribe get_states sample). Script entity_ids carry the
    # ``script.`` domain prefix in the state machine; created_ids hold the
    # script_id without the prefix.
    from ..utilities.wait_helpers import wait_for_entities_registered_via_ws

    expected_entity_ids = {f"script.{sid}" for sid in created_ids}
    seen = await wait_for_entities_registered_via_ws(expected_entity_ids, timeout=30.0)
    if seen != expected_entity_ids:
        from ..utilities.assertions import safe_call_tool
        from ..utilities.wait_helpers import wait_for_condition

        async def all_scripts_registered():
            for eid in expected_entity_ids:
                data = await safe_call_tool(
                    mcp_client, "ha_get_state", {"entity_id": eid}
                )
                if not (isinstance(data, dict) and data.get("data") is not None):
                    return False
            return True

        registered = await wait_for_condition(
            all_scripts_registered,
            condition_name=f"All {len(created_ids)} bulk scripts registered (fallback)",
            timeout=10,
        )
        if not registered:
            pytest.fail(
                f"bulk scripts never registered after WS + REST waits "
                f"(WS saw {len(seen)}/{len(expected_entity_ids)})"
            )

    yield created_ids

    for script_id in created_ids:
        try:
            await mcp_client.call_tool(
                "ha_config_remove_script",
                {"script_id": script_id},
            )
        except Exception as e:
            logger.warning(f"Cleanup failed for script {script_id}: {e}")
    logger.info(f"Cleaned up {len(created_ids)} bulk test scripts")


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_bulk_fetch_finds_automation_by_config_content(
    mcp_client, bulk_automations
):
    """
    Verify that deep search with bulk fetch finds automations by content that
    only appears inside their config (trigger entity_id), not in the name.

    This proves the bulk fetch actually populated config data, because a
    name-only search would never match the trigger entity.
    """
    # Search for a trigger entity that only exists inside config
    search_term = f"{_MARKER}_trigger_3"

    result = await mcp_client.call_tool(
        "ha_search",
        {
            "query": search_term,
            "search_types": ["automation"],
            "limit": 20,
            "include_config": True,
        },
    )
    data = assert_mcp_success(result, "Bulk fetch automation config search")

    automations = data.get("automations", [])
    assert len(automations) > 0, (
        f"Expected to find automation containing '{search_term}' in config. "
        "Bulk fetch may not have populated config data."
    )

    # The specific automation with trigger_3 should be in the results
    matched = [
        a
        for a in automations
        if f"{_MARKER} Automation 3" in a.get("friendly_name", "")
    ]
    assert len(matched) == 1, (
        f"Expected exactly 1 match for automation 3, got {len(matched)}: "
        f"{[a.get('friendly_name') for a in automations]}"
    )

    hit = matched[0]
    assert hit.get("match_in_config") is True, (
        "match_in_config should be True — the search term is only in config"
    )
    assert hit.get("config") is not None, (
        "Config object should be present in the result (proves bulk fetch worked)"
    )
    logger.info(f"Found automation 3 via config match, score={hit.get('score')}")


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_bulk_fetch_finds_script_by_config_content(mcp_client, bulk_scripts):
    """
    Verify that deep search with bulk fetch finds scripts by content that
    only appears inside their config (the unique payload message).
    """
    search_term = f"{_MARKER}_script_payload_2"

    result = await mcp_client.call_tool(
        "ha_search",
        {
            "query": search_term,
            "search_types": ["script"],
            "limit": 20,
            "include_config": True,
        },
    )
    data = assert_mcp_success(result, "Bulk fetch script config search")

    scripts = data.get("scripts", [])
    assert len(scripts) > 0, (
        f"Expected to find script containing '{search_term}' in config. "
        "Bulk fetch may not have populated script config data."
    )

    matched = [
        s for s in scripts if f"{_MARKER} Script 2" in s.get("friendly_name", "")
    ]
    assert len(matched) == 1, (
        f"Expected exactly 1 match for script 2, got {len(matched)}"
    )

    hit = matched[0]
    assert hit.get("match_in_config") is True, (
        "match_in_config should be True for a config-only match"
    )
    assert hit.get("config") is not None, (
        "Config object should be present (proves bulk fetch worked)"
    )
    logger.info(f"Found script 2 via config match, score={hit.get('score')}")


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_bulk_fetch_populates_config_for_multiple_results(
    mcp_client, bulk_automations
):
    """
    Search for the shared marker token that appears in ALL test automations'
    config. Verify that multiple results are returned and each has its config
    populated — this confirms bulk fetch didn't just fetch one config.
    """
    # The marker appears in every automation's action message and trigger entity
    result = await mcp_client.call_tool(
        "ha_search",
        {
            "query": f"{_MARKER}_payload",
            "search_types": ["automation"],
            "limit": 20,
            "include_config": True,
        },
    )
    data = assert_mcp_success(result, "Bulk fetch multi-result search")

    automations = data.get("automations", [])

    # We should find multiple automations (all share the marker in config)
    bulk_matches = [
        a for a in automations if f"{_MARKER} Automation" in a.get("friendly_name", "")
    ]
    assert len(bulk_matches) >= 3, (
        f"Expected at least 3 bulk test automations in results, got {len(bulk_matches)}. "
        f"Total results: {len(automations)}"
    )

    # Every matched automation should have config populated
    configs_present = sum(1 for a in bulk_matches if a.get("config") is not None)
    assert configs_present == len(bulk_matches), (
        f"All {len(bulk_matches)} matched automations should have config populated, "
        f"but only {configs_present} do. Bulk fetch may have partially failed."
    )

    logger.info(
        f"Found {len(bulk_matches)} automations with configs populated "
        f"(out of {_AUTOMATION_COUNT} created)"
    )


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_bulk_fetch_completes_within_timeout(
    mcp_client, bulk_automations, bulk_scripts
):
    """
    Verify that deep search across all types completes well within the
    30-second MCP timeout, even with multiple automations and scripts.

    This is not a strict performance test — it validates the bulk fetch
    strategy avoids the sequential-fetch timeout that prompted this PR.
    """
    start = time.perf_counter()

    result = await mcp_client.call_tool(
        "ha_search",
        {"query": _MARKER, "limit": 50},
    )
    data = assert_mcp_success(result, "Bulk fetch timing validation")

    elapsed = time.perf_counter() - start

    # The whole search should complete well under the 30s MCP timeout.
    # With bulk fetch working, this typically takes < 5 seconds.
    assert elapsed < 25.0, (
        f"Deep search took {elapsed:.1f}s — dangerously close to or exceeding "
        f"the 30s MCP timeout. Bulk fetch may not be working."
    )

    total = data.get("config_total_matches", 0)
    logger.info(
        f"Deep search completed in {elapsed:.1f}s, found {total} matches "
        f"across {_AUTOMATION_COUNT} automations + {_SCRIPT_COUNT} scripts"
    )


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_bulk_fetch_result_structure_integrity(mcp_client, bulk_automations):
    """
    Verify that results from bulk-fetched configs have the correct structure:
    entity_id, friendly_name, score, match flags, and config with expected keys.
    """
    result = await mcp_client.call_tool(
        "ha_search",
        {
            "query": f"{_MARKER}_trigger",
            "search_types": ["automation"],
            "limit": 20,
            "include_config": True,
        },
    )
    data = assert_mcp_success(result, "Bulk fetch structure check")

    automations = data.get("automations", [])
    assert len(automations) > 0, "Should find at least one automation"

    for auto in automations:
        if f"{_MARKER} Automation" not in auto.get("friendly_name", ""):
            continue

        # Required top-level fields
        assert "entity_id" in auto, "Missing entity_id"
        assert auto["entity_id"].startswith("automation."), "Bad entity_id format"
        assert "friendly_name" in auto, "Missing friendly_name"
        assert isinstance(auto.get("score"), (int, float)), "Score should be numeric"
        assert isinstance(auto.get("match_in_name"), bool), (
            "match_in_name should be bool"
        )
        assert isinstance(auto.get("match_in_config"), bool), (
            "match_in_config should be bool"
        )

        # Config should be a dict with automation keys
        config = auto.get("config")
        if config is not None:
            assert isinstance(config, dict), "Config should be a dict"
            # Bulk-fetched configs should contain the automation structure
            has_trigger = "trigger" in config or "triggers" in config
            has_action = "action" in config or "actions" in config
            assert has_trigger or has_action or "alias" in config, (
                f"Config should contain automation keys, got: {list(config.keys())}"
            )

    logger.info(f"Structure validation passed for {len(automations)} results")


# ---------------------------------------------------------------------------
# Pagination Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_deep_search_pagination_basic(mcp_client, bulk_automations):
    """
    Verify that offset/limit pagination returns correct slices and metadata.

    With 10 test automations sharing the marker in config, requesting limit=3
    should return 3 results with has_more=True, and offset=3 should return
    the next slice.
    """
    # First page: limit=3, offset=0
    result_page1 = await mcp_client.call_tool(
        "ha_search",
        {
            "query": _MARKER,
            "search_types": ["automation"],
            "limit": 3,
            "offset": 0,
        },
    )
    page1 = assert_mcp_success(result_page1, "Pagination page 1")

    assert page1.get("count") == 3, (
        f"Expected 3 results on page 1, got {page1.get('count')}"
    )
    assert page1.get("has_more") is True, (
        "has_more should be True when more results exist"
    )
    assert page1.get("next_offset") == 3, (
        f"next_offset should be 3, got {page1.get('next_offset')}"
    )
    total = page1.get("config_total_matches", 0)
    assert total >= _AUTOMATION_COUNT, (
        f"config_total_matches should be >= {_AUTOMATION_COUNT}, got {total}"
    )

    # Second page: limit=3, offset=3
    result_page2 = await mcp_client.call_tool(
        "ha_search",
        {
            "query": _MARKER,
            "search_types": ["automation"],
            "limit": 3,
            "offset": 3,
        },
    )
    page2 = assert_mcp_success(result_page2, "Pagination page 2")

    assert page2.get("count") == 3, (
        f"Expected 3 results on page 2, got {page2.get('count')}"
    )
    # config_total_matches should be the same across pages
    assert page2.get("config_total_matches") == total, (
        "config_total_matches should be consistent across pages"
    )

    # Verify no overlap between pages
    page1_ids = {a["entity_id"] for a in page1.get("automations", [])}
    page2_ids = {a["entity_id"] for a in page2.get("automations", [])}
    assert page1_ids.isdisjoint(page2_ids), (
        f"Pages should not overlap. Common IDs: {page1_ids & page2_ids}"
    )

    logger.info(
        f"Pagination test passed: {total} total, page1={len(page1_ids)}, page2={len(page2_ids)}"
    )


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_deep_search_pagination_last_page(mcp_client, bulk_automations):
    """
    Verify that requesting beyond available results returns has_more=False
    and next_offset=None.
    """
    # First get total count
    result_all = await mcp_client.call_tool(
        "ha_search",
        {
            "query": _MARKER,
            "search_types": ["automation"],
            "limit": 50,
        },
    )
    all_data = assert_mcp_success(result_all, "Get total count")
    total = all_data.get("config_total_matches", 0)

    # Request with offset past all results
    result_past = await mcp_client.call_tool(
        "ha_search",
        {
            "query": _MARKER,
            "search_types": ["automation"],
            "limit": 5,
            "offset": total,
        },
    )
    past_data = assert_mcp_success(result_past, "Pagination past end")

    assert past_data.get("count") == 0, (
        f"Expected 0 results past end, got {past_data.get('count')}"
    )
    assert past_data.get("has_more") is False, (
        "has_more should be False when offset >= total"
    )
    assert past_data.get("next_offset") is None, (
        "next_offset should be None on last page"
    )

    logger.info(f"Last page test passed: total={total}, count=0, has_more=False")


@pytest.mark.asyncio
@pytest.mark.e2e
async def test_deep_search_default_excludes_config(mcp_client, bulk_automations):
    """
    Verify that the default include_config=False strips config from results.
    This confirms the response slimming behavior.
    """
    result = await mcp_client.call_tool(
        "ha_search",
        {"query": _MARKER, "search_types": ["automation"], "limit": 5},
    )
    data = assert_mcp_success(result, "Default config exclusion check")

    automations = data.get("automations", [])
    assert len(automations) > 0, "Should find at least one automation"

    for auto in automations:
        assert "config" not in auto, (
            f"Config should be stripped by default, but found config in {auto.get('entity_id')}"
        )

    logger.info(
        f"Config exclusion test passed: {len(automations)} results, all without config"
    )
