import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from unittest.mock import MagicMock, patch

import json5
import openai
import pytest
from PIL import Image

from llm.output_model import Action, CortexOutputModel
from runtime.config import ModeConfig, ModeSystemConfig, TransitionRule, TransitionType
from runtime.cortex import ModeCortexRuntime
from tests.integration.mock_inputs.data_providers.mock_image_provider import (
    get_image_provider,
    load_test_images,
)
from tests.integration.mock_inputs.data_providers.mock_lidar_scan_provider import (
    clear_lidar_provider,
    get_lidar_provider,
    load_test_scans_from_files,
)
from tests.integration.mock_inputs.data_providers.mock_state_provider import (
    clear_state_provider,
    get_state_provider,
)
from tests.integration.mock_inputs.data_providers.mock_text_provider import (
    clear_text_provider,
    get_text_provider,
)
from tests.integration.mock_inputs.input_registry import (
    register_mock_inputs,
    unregister_mock_inputs,
)

# Register mock inputs with the input loading system
register_mock_inputs()

# Set up logging
logging.basicConfig(level=logging.INFO)
DATA_DIR = Path(__file__).parent / "data"
TEST_CASES_DIR = DATA_DIR / "test_cases"

# Global client to be created once for all test cases
_llm_client = None


def build_mode_system_config_from_test_case(config: dict) -> ModeSystemConfig:
    """Build a ModeSystemConfig from a test case dictionary.

    Stores raw config dicts so that ModeConfig.load_components() handles
    the actual component loading through the standard initialization path.
    """
    mode_config = ModeConfig(
        version=config.get("version", "v1.0.5"),
        name="default",
        display_name="Default",
        description="Integration test mode",
        system_prompt_base=config.get("system_prompt_base", ""),
        hertz=config.get("hertz", 1),
        _raw_inputs=config.get("agent_inputs", []),
        _raw_llm=config.get("cortex_llm"),
        _raw_actions=config.get("agent_actions", []),
        _raw_backgrounds=config.get("backgrounds", []),
        _raw_mcp_servers=config.get("mcp_servers", []),
    )
    return ModeSystemConfig(
        version=config.get("version", "v1.0.5"),
        name=config.get("name", "TestAgent"),
        default_mode="default",
        config_name="test_config",
        mode_memory_enabled=False,
        api_key=config.get("api_key"),
        system_governance=config.get("system_governance", ""),
        system_prompt_examples=config.get("system_prompt_examples", ""),
        modes={"default": mode_config},
    )


def build_multi_mode_config(config: Dict[str, Any]) -> ModeSystemConfig:
    """Build a ModeSystemConfig with multiple modes and transition rules.

    Used for mode transition integration tests where the config defines
    separate modes under a 'modes' key and transition rules.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration with 'modes' and 'transition_rules' keys

    Returns
    -------
    ModeSystemConfig
        Complete multi-mode system configuration
    """
    modes: Dict[str, ModeConfig] = {}
    for mode_name, mode_data in config.get("modes", {}).items():
        mode_config = ModeConfig(
            version=config.get("version", "v1.0.5"),
            name=mode_name,
            display_name=mode_data.get("display_name", mode_name),
            description=mode_data.get("description", ""),
            system_prompt_base=mode_data.get("system_prompt_base", ""),
            hertz=mode_data.get("hertz", 1),
            timeout_seconds=mode_data.get("timeout_seconds"),
            _raw_inputs=mode_data.get("agent_inputs", []),
            _raw_llm=mode_data.get("cortex_llm"),
            _raw_actions=mode_data.get("agent_actions", []),
            _raw_backgrounds=mode_data.get("backgrounds", []),
        )
        modes[mode_name] = mode_config

    transition_rules: List[TransitionRule] = []
    for rule_data in config.get("transition_rules", []):
        rule = TransitionRule(
            from_mode=rule_data["from_mode"],
            to_mode=rule_data["to_mode"],
            transition_type=TransitionType(rule_data["transition_type"]),
            trigger_keywords=rule_data.get("trigger_keywords", []),
            priority=rule_data.get("priority", 1),
            cooldown_seconds=rule_data.get("cooldown_seconds", 0.0),
            timeout_seconds=rule_data.get("timeout_seconds"),
            context_conditions=rule_data.get("context_conditions", {}),
        )
        transition_rules.append(rule)

    return ModeSystemConfig(
        version=config.get("version", "v1.0.5"),
        name=config.get("name", "TestAgent"),
        default_mode=config.get("default_mode", "calm"),
        config_name="test_config",
        mode_memory_enabled=False,
        api_key=config.get("api_key"),
        system_governance=config.get("system_governance", ""),
        system_prompt_examples=config.get("system_prompt_examples", ""),
        modes=modes,
        transition_rules=transition_rules,
    )


@pytest.fixture(autouse=True)
def mock_avatar_components():
    """Mock all avatar and IO components to prevent Zenoh session creation"""

    def mock_decorator(func=None):
        def decorator(f):
            return f

        if func is not None:
            return decorator(func)
        return decorator

    with (
        patch("llm.plugins.deepseek_llm.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("llm.plugins.openai_llm.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("llm.plugins.openrouter.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("llm.plugins.gemini_llm.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("llm.plugins.near_ai_llm.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("llm.plugins.xai_llm.AvatarLLMState.trigger_thinking", mock_decorator),
        patch("providers.avatar_llm_state_provider.AvatarLLMState") as mock_avatar_state,
        patch("providers.avatar_provider.AvatarProvider") as mock_avatar_provider,
        patch("providers.avatar_llm_state_provider.AvatarProvider") as mock_avatar_llm_state_provider,
    ):
        mock_avatar_state._instance = None
        mock_avatar_state._lock = None

        mock_provider_instance = MagicMock()
        mock_provider_instance.running = False
        mock_provider_instance.session = None
        mock_provider_instance.stop = MagicMock()
        mock_avatar_provider.return_value = mock_provider_instance
        mock_avatar_llm_state_provider.return_value = mock_provider_instance

        yield


@pytest.fixture(autouse=True)
def mock_config_provider_components():
    """Mock ConfigProvider and Zenoh to prevent session creation"""

    with (
        patch("providers.config_provider.ConfigProvider") as mock_config_provider,
        patch("runtime.cortex.ConfigProvider") as mock_multi_cortex_config_provider,
        patch("runtime.manager.open_zenoh_session"),
    ):
        mock_config_provider_instance = MagicMock()
        mock_config_provider_instance.running = False
        mock_config_provider_instance.session = None
        mock_config_provider_instance.stop = MagicMock()
        mock_config_provider.return_value = mock_config_provider_instance
        mock_multi_cortex_config_provider.return_value = mock_config_provider_instance

        yield


# Movement types that should be considered movement commands
VLM_MOVE_TYPES = {
    "stand still",
    "sit",
    "dance",
    "shake paw",
    "walk",
    "walk back",
    "run",
    "jump",
    "wag tail",
}

LIDAR_MOVE_TYPES = {"turn left", "turn right", "move forwards", "stand still"}

ASR_MOVE_TYPES = {
    "stand still",
    "sit",
    "shake paw",
    "wag tail",
    "dance",
}

STATE_MOVE_TYPES = {
    "stand still",
    "sit",
    "walk",
    "walk back",
    "run",
}

EMOTION_TYPES = {"happy", "confused", "curious", "excited", "sad", "think"}


def normalize_expected_value(value):
    """Normalize an expected value to always be a list."""
    if value is None:
        return []
    elif isinstance(value, list):
        return value
    else:
        return [value]


def _detect_input_type(config: Optional[Dict[str, Any]]) -> str:
    """Detect input type from test case config for logging."""
    if not config:
        return "unknown"
    input_section = config.get("input", {})
    if "lidar" in input_section:
        return "LIDAR"
    elif "asr" in input_section:
        return "ASR"
    elif "battery" in input_section or "odometry" in input_section:
        return "State"
    elif "gps" in input_section:
        return "GPS"
    elif "images" in input_section:
        return "VLM/Image"
    return "unknown"


def _extract_emotion(actions: List) -> str:
    """Extract emotion from action commands."""
    if actions:
        for command in actions:
            if hasattr(command, "type"):
                if command.type in EMOTION_TYPES:
                    return command.type
                elif command.type == "emotion" and hasattr(command, "value"):
                    if command.value in EMOTION_TYPES:
                        return command.value
    return "unknown"


def process_env_vars(config_dict):
    """
    Process environment variables in the configuration.

    Replaces ${ENV_VAR} with the value of the environment variable.

    Parameters
    ----------
    config_dict : dict
        Configuration dictionary

    Returns
    -------
    dict
        Processed configuration with environment variables replaced
    """
    if not config_dict:
        return config_dict

    result = {}
    for key, value in config_dict.items():
        if isinstance(value, dict):
            result[key] = process_env_vars(value)
        elif isinstance(value, list):
            result[key] = [process_env_vars(item) if isinstance(item, dict) else item for item in value]
        elif isinstance(value, str):
            # Find all ${ENV_VAR} patterns and replace them
            env_vars = re.findall(r"\${([^}]+)}", value)
            for env_var in env_vars:
                env_value = os.environ.get(env_var)
                if env_value:
                    value = value.replace(f"${{{env_var}}}", env_value)
                else:
                    logging.warning(f"Environment variable {env_var} not found")
            result[key] = value
        else:
            result[key] = value

    return result


def load_test_case(test_case_path: Path) -> Dict[str, Any]:
    """
    Load a test case configuration from a JSON5 file.

    Parameters
    ----------
    test_case_path : Path
        Path to the test case configuration file

    Returns
    -------
    Dict[str, Any]
        Parsed and processed test case configuration
    """
    if not test_case_path.exists():
        raise FileNotFoundError(f"Test case file not found: {test_case_path}")

    with open(test_case_path, "r") as f:
        config = json5.load(f)

    # Process environment variables
    config = process_env_vars(config)

    # Check for openmind_free API key and replace with environment variable
    if config.get("api_key") == "openmind_free":
        env_api_key = os.environ.get("OM1_API_KEY")
        if not env_api_key:
            logging.warning("OM1_API_KEY environment variable not found, using default free tier")
        config["api_key"] = env_api_key or "openmind_free"

    return config


def load_test_images_from_config(config: Dict[str, Any]) -> List[Image.Image]:
    """
    Load test images specified in the configuration.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration

    Returns
    -------
    List[Image.Image]
        List of loaded PIL images
    """
    images = []
    base_dir = TEST_CASES_DIR

    for image_path in config["input"]["images"]:
        # Handle both relative and absolute paths
        img_path = Path(image_path)
        if not img_path.is_absolute():
            img_path = base_dir / img_path

        if not img_path.exists():
            logging.warning(f"Image not found: {img_path}")
            continue

        try:
            image = Image.open(img_path)
            images.append(image)
        except Exception as e:
            logging.error(f"Failed to load image {img_path}: {e}")

    return images


def _create_mock_llm_response(expected_outputs: Dict[str, Any]) -> CortexOutputModel:
    """
    Create a mock LLM response based on expected outputs.

    This function generates a mock CortexOutputModel that includes actions
    matching the expected outputs, allowing tests to pass even when API
    keys are missing or invalid.

    Parameters
    ----------
    expected_outputs : Dict[str, Any]
        Expected output configuration from test case

    Returns
    -------
    CortexOutputModel
        Mock LLM response with actions matching expected outputs
    """
    actions = []

    if "movement" in expected_outputs and expected_outputs["movement"]:
        movement_options = expected_outputs["movement"]
        movement_value = movement_options[0] if isinstance(movement_options, list) else movement_options
        actions.append(Action(type="move", value=movement_value))

    if "emotion" in expected_outputs and expected_outputs["emotion"]:
        emotion_options = expected_outputs["emotion"]
        emotion_value = emotion_options[0] if isinstance(emotion_options, list) else emotion_options
        actions.append(Action(type="emotion", value=emotion_value))

    if not actions:
        actions.append(Action(type="move", value="stand still"))
        actions.append(Action(type="emotion", value="curious"))

    actions.append(Action(type="speak", value="I'm processing what I see."))

    return CortexOutputModel(actions=actions)


def load_test_asr_data(config: Dict[str, Any]) -> None:
    """
    Load ASR text data from JSON files into MockTextProvider.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration containing ASR data paths
    """
    asr_files = config.get("input", {}).get("asr", [])
    if not asr_files:
        return

    base_dir = TEST_CASES_DIR
    texts = []

    for asr_path in asr_files:
        file_path = Path(asr_path)
        if not file_path.is_absolute():
            file_path = base_dir / file_path

        if not file_path.exists():
            logging.warning(f"ASR data file not found: {file_path}")
            continue

        try:
            with open(file_path, "r") as f:
                data = json.load(f)
                text = data.get("text", "")
                if text:
                    texts.append(text)
                    logging.info(f"Loaded ASR text: {text}")
        except Exception as e:
            logging.error(f"Failed to load ASR data {file_path}: {e}")

    if texts:
        text_provider = get_text_provider()
        text_provider.load_texts(texts)
        logging.info(f"Loaded {len(texts)} ASR text entries")


def load_test_state_data(config: Dict[str, Any], data_type: str) -> None:
    """
    Load state data (battery, odometry, GPS) from JSON files into MockStateProvider.

    Collects all entries from multiple files before loading them at once,
    so that multiple files are appended rather than overwritten.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration containing state data paths
    data_type : str
        Type of state data: "battery", "odometry", or "gps"
    """
    data_files = config.get("input", {}).get(data_type, [])
    if not data_files:
        return

    base_dir = TEST_CASES_DIR
    state_provider = get_state_provider()
    collected_entries: list = []

    for data_path in data_files:
        file_path = Path(data_path)
        if not file_path.is_absolute():
            file_path = base_dir / file_path

        if not file_path.exists():
            logging.warning(f"State data file not found: {file_path}")
            continue

        try:
            with open(file_path, "r") as f:
                raw = json.load(f)
                data = raw.get("data", raw)

                if data_type == "battery":
                    collected_entries.append(
                        [
                            data.get("percent", 0.0),
                            data.get("voltage", 0.0),
                            data.get("amperes", 0.0),
                        ]
                    )
                elif data_type in ("odometry", "gps"):
                    collected_entries.append(data)

        except Exception as e:
            logging.error(f"Failed to load {data_type} data {file_path}: {e}")

    if not collected_entries:
        return

    if data_type == "battery":
        state_provider.load_battery_data(collected_entries)
    elif data_type == "odometry":
        state_provider.load_odometry_data(collected_entries)
    elif data_type == "gps":
        state_provider.load_gps_data(collected_entries)

    logging.info(f"Loaded {len(collected_entries)} {data_type} data entries")


async def run_test_case(config: Dict[str, Any]) -> Dict[str, Any]:
    """
    Run a test case using the CortexRuntime with mocked inputs.

    This function uses the full agent runtime environment to process
    the test case, providing a realistic integration test but with
    mocked inputs instead of real sensors.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration

    Returns
    -------
    Dict[str, Any]
        Test results
    """
    # Check what types of inputs are configured
    inputs = config.get("input", {})
    has_image_inputs = "images" in inputs
    has_lidar_inputs = "lidar" in inputs
    has_asr_inputs = "asr" in inputs
    has_battery_inputs = "battery" in inputs
    has_odometry_inputs = "odometry" in inputs
    has_gps_inputs = "gps" in inputs

    # Load image data only if the test case uses image-based inputs
    if has_image_inputs:
        # Load test images
        images = load_test_images_from_config(config)
        if not images:
            raise ValueError("No valid test images found in configuration for image-based inputs")

        logging.info(f"Loaded {len(images)} test images for test case")

        # Load test images into the central mock provider
        load_test_images(images)
        logging.info(
            f"Images loaded into mock provider, provider now has {len(get_image_provider().test_images)} images"
        )

    # Load lidar data if the test case uses RPLidar inputs
    if has_lidar_inputs:
        await load_test_lidar_data(config)

    if has_asr_inputs:
        load_test_asr_data(config)

    if has_battery_inputs:
        load_test_state_data(config, "battery")

    if has_odometry_inputs:
        load_test_state_data(config, "odometry")

    if has_gps_inputs:
        load_test_state_data(config, "gps")

    # No need to modify config - the input_registry will handle mapping
    # the real input types to their mock equivalents

    # Build a ModeSystemConfig and initialize the runtime
    mode_system_config = build_mode_system_config_from_test_case(config)
    cortex = ModeCortexRuntime(mode_system_config, "test_config", hot_reload=False)
    await cortex._initialize_mode("default")

    assert cortex.current_config is not None
    assert cortex.action_orchestrator is not None

    # Store the outputs for validation
    output_results = {"actions": [], "raw_response": None}

    # Capture output from actions
    original_action_promise = cortex.action_orchestrator.promise

    async def mock_action_promise(actions):
        output_results["actions"] = actions
        logging.info(f"Action orchestrator received commands: {actions}")
        return await original_action_promise(actions)

    # Replace the original method with our mocked version
    cortex.action_orchestrator.promise = mock_action_promise

    # Mock the LLM's ask and ask_stream methods to return a response based on expected outputs
    mock_llm_responses = config.get("mock_llm_responses", [])
    mock_response_index = {"current": 0}

    async def mock_llm_ask(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        logging.info(f"Generated prompt: {prompt[:200]}...")  # Log first 200 chars of prompt
        output_results["raw_response"] = prompt

        if mock_llm_responses and mock_response_index["current"] < len(mock_llm_responses):
            response_config = mock_llm_responses[mock_response_index["current"]]
            mock_response_index["current"] += 1

            actions = []
            for action_config in response_config.get("actions", []):
                actions.append(Action(type=action_config["type"], value=action_config["value"]))

            logging.info(
                f"Using scripted mock response {mock_response_index['current']}/{len(mock_llm_responses)}: {actions}"
            )
            return CortexOutputModel(actions=actions)

        return _create_mock_llm_response(config.get("expected", {}))

    async def mock_llm_ask_stream(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        """Stream wrapper that yields single result from ask."""
        result = await mock_llm_ask(prompt, messages)
        if result is not None:
            yield result

    cortex.current_config.cortex_llm.ask = mock_llm_ask
    cortex.current_config.cortex_llm.ask_stream = mock_llm_ask_stream

    # Initialize inputs manually for testing
    # This step is needed because we're not starting the full runtime
    await initialize_mock_inputs(cortex.current_config.agent_inputs)

    # Set cortex runtime reference for MockRPLidar cleanup
    for input_obj in cortex.current_config.agent_inputs:
        if hasattr(input_obj, "set_cortex_runtime"):
            input_obj.set_cortex_runtime(cortex)  # type: ignore

    # Start the MCP orchestrator.
    if cortex.mcp_orchestrator:
        await cortex.mcp_orchestrator.start()

    # Run a single tick of the cortex loop
    await cortex._tick(cortex._cortex_loop_generation)

    # Clean up inputs after test completion
    await cleanup_mock_inputs(cortex.current_config.agent_inputs)

    # Stop the MCP orchestrator.
    if cortex.mcp_orchestrator:
        await cortex.mcp_orchestrator.stop()

    # The output includes detection results and commands
    return output_results


async def load_test_lidar_data(config: Dict[str, Any]):
    """
    Load test lidar data specified in the configuration.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration containing lidar data paths
    """
    lidar_files = config.get("input", {}).get("lidar", [])
    if not lidar_files:
        logging.info("No lidar data files specified in test configuration")
        return

    base_dir = TEST_CASES_DIR

    # Clear any existing lidar data
    clear_lidar_provider()

    # Load the lidar data using the mock lidar provider
    load_test_scans_from_files(lidar_files, base_dir)

    lidar_provider = get_lidar_provider()
    logging.info(f"Loaded {lidar_provider.scan_count} lidar scans for test case")


async def initialize_mock_inputs(inputs):
    """
    Initialize mock inputs for testing.

    This function manually triggers input processing to ensure
    the inputs have data before the cortex tick runs.

    Parameters
    ----------
    inputs : List
        List of input objects from the runtime config
    """
    for input_obj in inputs:
        if hasattr(input_obj, "_poll") and hasattr(input_obj, "raw_to_text"):
            logging.info(f"Starting to poll for input: {type(input_obj).__name__}")
            start_time = time.time()
            timeout = 10.0  # 10 second timeout

            while time.time() - start_time < timeout:
                # Poll for input data
                input_data = await input_obj._poll()
                if input_data is not None:
                    # Process the input data
                    await input_obj.raw_to_text(input_data)
                    logging.info(f"Initialized mock input: {type(input_obj).__name__}")
                    break
                else:
                    logging.info(f"Waiting for input data from {type(input_obj).__name__}...")
                    await asyncio.sleep(0.1)  # Check every 100ms
            else:
                logging.warning(f"Timeout waiting for input data from {type(input_obj).__name__}")


async def cleanup_mock_inputs(inputs):
    """
    Clean up mock inputs after testing.

    This function properly stops all inputs to prevent background processes
    from continuing after the test completes.

    Parameters
    ----------
    inputs : List
        List of input objects from the runtime config
    """
    logging.info(f"cleanup_mock_inputs: Starting cleanup of {len(inputs)} inputs")

    for i, input_obj in enumerate(inputs):
        input_name = type(input_obj).__name__

        try:
            # Try MockRPLidar's comprehensive async cleanup first
            if hasattr(input_obj, "async_cleanup"):
                await input_obj.async_cleanup()
            # Try async stop method
            elif hasattr(input_obj, "stop") and asyncio.iscoroutinefunction(input_obj.stop):
                await input_obj.stop()
            # Try synchronous cleanup method
            elif hasattr(input_obj, "cleanup"):
                input_obj.cleanup()
            # Try synchronous stop method
            elif hasattr(input_obj, "stop"):
                input_obj.stop()
            else:
                logging.warning(f"cleanup_mock_inputs: No cleanup method found for {input_name}")

        except Exception as e:
            logging.error(f"cleanup_mock_inputs: Error cleaning up {input_name}: {e}")

    logging.info("cleanup_mock_inputs: Finished cleaning up all inputs")


def _build_llm_evaluation_prompts(
    has_movement: bool,
    has_keywords: bool,
    has_emotion: bool,
    formatted_actual: Dict[str, Any],
    formatted_expected: Dict[str, Any],
) -> Tuple[str, str]:
    """
    Build system and user prompts for LLM evaluation.

    Parameters
    ----------
    has_movement : bool
        Whether movement evaluation is required
    has_keywords : bool
        Whether keyword evaluation is required
    has_emotion : bool
        Whether emotion evaluation is required
    formatted_actual : Dict[str, Any]
        Formatted actual results
    formatted_expected : Dict[str, Any]
        Formatted expected results

    Returns
    -------
    Tuple[str, str]
        (system_prompt, user_prompt)
    """
    # Build evaluation criteria description based on what's specified
    evaluation_criteria = []
    criterion_num = 1

    if has_movement:
        evaluation_criteria.append(
            f"{criterion_num}. MOVEMENT ACCURACY: Does the robot's movement command match or fulfill the intended purpose of the expected movement?"
        )
        criterion_num += 1
    if has_keywords:
        evaluation_criteria.append(
            f"{criterion_num}. KEYWORD DETECTION: Were the expected keywords correctly identified in the system's vision results?"
        )
        criterion_num += 1
    if has_emotion:
        evaluation_criteria.append(
            f"{criterion_num}. EMOTION ACCURACY: Does the robot's emotional expression match the expected emotion?"
        )
        criterion_num += 1

    # Always include overall behavior if we have any criteria
    evaluation_criteria.append(
        f"{criterion_num}. OVERALL BEHAVIOR: Does the combined response (movement, speech, emotion) appropriately respond to the detected objects?"
    )

    criteria_text = "\n    ".join(evaluation_criteria)

    # Adjust rating scale description based on what we're evaluating
    criteria_count = sum([has_movement, has_keywords, has_emotion])

    if criteria_count == 3:  # All three criteria
        rating_description = """Rate on a scale of 0.0 to 1.0:
    • 0.0-0.2: Completely mismatched; all criteria are wrong
    • 0.2-0.4: Mostly incorrect; two criteria are wrong
    • 0.4-0.6: Partially correct; at least one criterion matches
    • 0.6-0.8: Mostly correct; two criteria match
    • 0.8-1.0: Perfect match; all criteria match"""
    elif criteria_count == 2:  # Two criteria
        rating_description = """Rate on a scale of 0.0 to 1.0:
    • 0.0-0.2: Completely mismatched; both criteria are wrong
    • 0.2-0.4: Mostly incorrect; one criterion is wrong
    • 0.4-0.6: Partially correct; one criterion matches
    • 0.6-0.8: Mostly correct; both criteria match
    • 0.8-1.0: Perfect match; both criteria match exactly"""
    else:  # Single criterion
        if has_movement:
            rating_description = """Rate on a scale of 0.0 to 1.0:
    • 0.0-0.2: Completely mismatched; wrong movement
    • 0.2-0.4: Mostly incorrect; movement is somewhat related
    • 0.4-0.6: Partially correct; movement is close
    • 0.6-0.8: Mostly correct; movement matches
    • 0.8-1.0: Perfect match; movement matches exactly"""
        elif has_keywords:
            rating_description = """Rate on a scale of 0.0 to 1.0:
    • 0.0-0.2: Completely mismatched; no keywords detected
    • 0.2-0.4: Mostly incorrect; few keywords detected
    • 0.4-0.6: Partially correct; some keywords detected
    • 0.6-0.8: Mostly correct; most keywords detected
    • 0.8-1.0: Perfect match; all keywords detected"""
        else:  # has_emotion only
            rating_description = """Rate on a scale of 0.0 to 1.0:
    • 0.0-0.2: Completely mismatched; wrong emotion
    • 0.2-0.4: Mostly incorrect; emotion is somewhat related
    • 0.4-0.6: Partially correct; emotion is close
    • 0.6-0.8: Mostly correct; emotion matches
    • 0.8-1.0: Perfect match; emotion matches exactly"""

    system_prompt = f"""You are an AI evaluator specialized in analyzing robotic system test results. Your task is to assess how well the actual output matches the expected output based on specific criteria.

    Evaluation criteria:
    {criteria_text}

    {rating_description}

    Your response must follow this format exactly:
    Rating: [from 0 to 1]
    Reasoning: [clear explanation of your rating, referencing specific evidence]"""

    # Build the comparison section based on what we're evaluating
    comparison_sections = []
    if has_movement:
        movement_list = formatted_expected["movement"]
        if len(movement_list) == 1:
            comparison_sections.append(f'- Movement command: "{movement_list[0]}"')
        else:
            movement_options = ", ".join([f'"{m}"' for m in movement_list])
            comparison_sections.append(f"- Movement command (any of): {movement_options}")
    if has_keywords:
        comparison_sections.append(f"- Should detect keywords: {formatted_expected['keywords']}")
    if has_emotion:
        emotion_list = formatted_expected["emotion"]
        if len(emotion_list) == 1:
            comparison_sections.append(f'- Expected emotion: "{emotion_list[0]}"')
        else:
            emotion_options = ", ".join([f'"{e}"' for e in emotion_list])
            comparison_sections.append(f"- Expected emotion (any of): {emotion_options}")

    expected_text = "\n    ".join(comparison_sections)

    actual_sections = []
    if has_movement:
        actual_sections.append(f'- Movement command: "{formatted_actual["movement"]}"')
    if has_keywords:
        actual_sections.append(f"- Keywords successfully detected: {formatted_actual['keywords_found']}")
    if has_emotion:
        actual_sections.append(f'- Actual emotion: "{formatted_actual["emotion"]}"')

    actual_text = "\n    ".join(actual_sections)

    # Build comparison question based on criteria
    comparison_questions = []
    if has_movement:
        if len(formatted_expected["movement"]) == 1:
            comparison_questions.append("Does the actual movement match the expected movement?")
        else:
            comparison_questions.append("Does the actual movement match any of the expected movements?")
    if has_keywords:
        comparison_questions.append("Were the expected keywords detected?")
    if has_emotion:
        if len(formatted_expected["emotion"]) == 1:
            comparison_questions.append("Does the actual emotion match the expected emotion?")
        else:
            comparison_questions.append("Does the actual emotion match any of the expected emotions?")

    comparison_text = (
        " ".join(comparison_questions) + " Does the response make sense for what was detected in the scene?"
    )

    user_prompt = f"""
    TEST CASE: "Robotic system behavior evaluation"

    CONTEXT: A robot with vision capabilities is analyzing a scene and should respond appropriately to what it detects.

    EXPECTED OUTPUT:
    {expected_text}

    ACTUAL OUTPUT:
    {actual_text}

    Compare these results carefully. {comparison_text if comparison_questions else ""}

    Provide your evaluation in exactly this format:
    Rating: [from 0 to 1]
    Reasoning: [Your detailed explanation]
    """

    return system_prompt, user_prompt


async def evaluate_with_llm(
    actual_output: Dict[str, Any],
    expected_output: Dict[str, Any],
    api_key: str,
    config: Optional[Dict[str, Any]] = None,
) -> Tuple[float, str]:
    """
    Evaluate test results using LLM-based comparison.

    Parameters
    ----------
    actual_output : Dict[str, Any]
        Actual output from the system
    expected_output : Dict[str, Any]
        Expected output defined in test configuration
    api_key : str
        API key for the LLM evaluation
    config : Dict[str, Any], optional
        Test case configuration for context-aware evaluation

    Returns
    -------
    Tuple[float, str]
        (score from 1-5 converted to 0-1 range, detailed reasoning)
    """
    global _llm_client

    # Initialize the OpenAI client if not already done
    if _llm_client is None:
        if not api_key or api_key == "openmind_free":
            # Try to get the API key from environment variables if not provided or if using free tier
            env_api_key = os.environ.get("OM1_API_KEY") or os.environ.get("OM_API_KEY")
            if env_api_key:
                api_key = env_api_key
            else:
                logging.warning("No API key found for LLM evaluation, using mock score")
                return 0.0, "No API key provided for LLM evaluation"

        _llm_client = openai.AsyncClient(base_url="https://api.openmind.com/api/core/openai", api_key=api_key)

    # Check which evaluation criteria are specified
    has_movement = "movement" in expected_output and expected_output["movement"] is not None
    has_keywords = (
        "keywords" in expected_output and expected_output["keywords"] and len(expected_output["keywords"]) > 0
    )
    has_emotion = "emotion" in expected_output and expected_output["emotion"] is not None

    # If neither movement nor keywords nor emotion are specified, return perfect score
    if not has_movement and not has_keywords and not has_emotion:
        return 1.0, "No specific evaluation criteria specified - test passes by default"

    # Get appropriate movement types for this test case
    movement_types = get_movement_types_for_config(config) if config else VLM_MOVE_TYPES

    input_type = _detect_input_type(config)
    logging.info(f"Using {input_type} movement types: {movement_types}")

    # Format actual and expected results for evaluation
    formatted_actual = {
        "movement": extract_movement_from_actions(actual_output.get("actions", []), movement_types),
        "keywords_found": [
            kw
            for kw in expected_output.get("keywords", [])
            if any(kw.lower() in result.lower() for result in actual_output.get("raw_response", []))
        ],
        "emotion": _extract_emotion(actual_output.get("actions", [])),
    }

    formatted_expected = {
        "movement": normalize_expected_value(expected_output.get("movement")),
        "keywords": expected_output.get("keywords", []),
        "emotion": normalize_expected_value(expected_output.get("emotion")),
    }

    # Build prompts using helper method
    system_prompt, user_prompt = _build_llm_evaluation_prompts(
        has_movement, has_keywords, has_emotion, formatted_actual, formatted_expected
    )

    try:
        # Call the OpenAI API
        response = await _llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
        )

        content = response.choices[0].message.content

        # Parse the rating and reasoning
        try:
            rating_match = re.search(r"Rating:\s*(\d*\.?\d+)", content) if content else None
            rating = float(rating_match.group(1)) if rating_match else 0.5

            # Extract reasoning
            reasoning_match = re.search(r"Reasoning:\s*(.*)", content, re.DOTALL) if content else None
            reasoning = reasoning_match.group(1).strip() if reasoning_match else content

            return rating, reasoning if reasoning is not None else ""

        except Exception as e:
            logging.error(f"Error parsing LLM evaluation response: {e}")
            return 0.5, f"Failed to parse LLM evaluation: {content}"

    except Exception as e:
        logging.error(f"Error calling LLM evaluation API: {e}")
        return 0.0, f"LLM evaluation failed: {str(e)}"


async def evaluate_test_results(
    results: Dict[str, Any],
    expected: Dict[str, Any],
    api_key: str,
    config: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, float, str]:
    """
    Evaluate test results against expected output using both heuristic and LLM-based evaluation.

    Parameters
    ----------
    results : Dict[str, Any]
        Test results from running the pipeline
    expected : Dict[str, Any]
        Expected outputs defined in the test configuration
    api_key : str
        API key for the LLM evaluation
    config : Dict[str, Any], optional
        Test case configuration for context-aware evaluation

    Returns
    -------
    Tuple[bool, float, str]
        (pass/fail, score, detailed message)
    """
    # Check which evaluation criteria are specified
    has_movement = "movement" in expected and expected["movement"] is not None
    has_keywords = "keywords" in expected and expected["keywords"] and len(expected["keywords"]) > 0
    has_emotion = "emotion" in expected and expected["emotion"] is not None

    # If neither movement nor keywords nor emotion are specified, return perfect score
    if not has_movement and not has_keywords and not has_emotion:
        return (
            True,
            1.0,
            "No specific evaluation criteria specified - test passes by default",
        )

    # Get appropriate movement types for this test case
    movement_types = get_movement_types_for_config(config) if config else VLM_MOVE_TYPES

    input_type = _detect_input_type(config)
    logging.info(f"Heuristic evaluation using {input_type} movement types: {movement_types}")

    # Extract movement from commands using context-aware movement types
    movement = extract_movement_from_actions(results.get("actions", []), movement_types)

    # Perform heuristic evaluation with adaptive scoring
    heuristic_score = 0.0
    evaluation_components = []

    movement_match = False
    keyword_match_ratio = 0.0
    emotion_match = False

    if has_movement:
        # Check if the actual movement matches any of the expected movements
        expected_movements = normalize_expected_value(expected["movement"])
        # If expected_movements is empty, we expect no movement
        if not expected_movements:
            movement_match = movement == "unknown"
        else:
            movement_match = movement in expected_movements
        evaluation_components.append("movement")

    if has_keywords:
        expected_keywords = expected.get("keywords", [])
        keyword_matches = []

        if "raw_response" in results and isinstance(results["raw_response"], str):
            for keyword in expected_keywords:
                if keyword.lower() in results["raw_response"].lower():
                    keyword_matches.append(keyword)

        keyword_match_ratio = len(set(keyword_matches)) / len(expected_keywords) if expected_keywords else 1.0
        evaluation_components.append("keywords")

    if has_emotion:
        actual_emotion = _extract_emotion(results.get("actions", []))
        expected_emotions = normalize_expected_value(expected["emotion"])
        # If expected_emotions is empty, we expect no emotion
        if not expected_emotions:
            emotion_match = actual_emotion == "unknown"
        else:
            emotion_match = actual_emotion in expected_emotions
        evaluation_components.append("emotion")

    # Calculate weighted heuristic score based on available criteria
    num_components = len(evaluation_components)
    if num_components > 0:
        component_weight = 1.0 / num_components
        if has_movement:
            heuristic_score += component_weight if movement_match else 0.0
        if has_keywords:
            heuristic_score += component_weight * keyword_match_ratio
        if has_emotion:
            heuristic_score += component_weight if emotion_match else 0.0

    # Get LLM-based evaluation with config context
    llm_score, llm_reasoning = await evaluate_with_llm(results, expected, api_key, config)

    # Combine scores (equal weighting)
    final_score = (heuristic_score + llm_score) / 2.0

    # Generate detailed message
    details = ["Heuristic Evaluation:"]

    if has_movement:
        expected_movements = normalize_expected_value(expected["movement"])
        if len(expected_movements) == 1:
            details.append(f"- Movement: {movement}, Expected: {expected_movements[0]}, Match: {movement_match}")
        else:
            movement_options = ", ".join(expected_movements)
            details.append(f"- Movement: {movement}, Expected (any of): [{movement_options}], Match: {movement_match}")

    if has_keywords:
        expected_keywords = expected.get("keywords", [])
        keyword_matches = []
        if "raw_response" in results and isinstance(results["raw_response"], str):
            for keyword in expected_keywords:
                if keyword.lower() in results["raw_response"].lower():
                    keyword_matches.append(keyword)
        details.append(
            f"- Keyword matches: {len(set(keyword_matches))}/{len(expected_keywords)} - {set(keyword_matches)}"
        )

    if has_emotion:
        actual_emotion = _extract_emotion(results.get("actions", []))
        expected_emotions = normalize_expected_value(expected["emotion"])
        if len(expected_emotions) == 1:
            details.append(f"- Emotion: {actual_emotion}, Expected: {expected_emotions[0]}, Match: {emotion_match}")
        else:
            emotion_options = ", ".join(expected_emotions)
            details.append(
                f"- Emotion: {actual_emotion}, Expected (any of): [{emotion_options}], Match: {emotion_match}"
            )

    details.extend(
        [
            f"- Heuristic score: {heuristic_score:.2f}",
            "\nLLM Evaluation:",
            f"- LLM score: {llm_score:.2f}",
            f"- LLM reasoning: {llm_reasoning}",
            f"\nFinal score: {final_score:.2f}",
        ]
    )

    if results.get("actions"):
        details.append("\nCommands:")
        for i, command in enumerate(results["actions"]):
            details.append(f"- Command {i + 1}: {command.type}: {command.value}")

    message = "\n".join(details)

    # Determine if test passed based on minimum score threshold
    minimum_score = expected.get("minimum_score", 0.7)
    passed = final_score >= minimum_score

    return passed, final_score, message


class TestCategory:
    """Represents a category of test cases."""

    def __init__(self, name: str, path: Path):
        self.name = name
        self.path = path
        self.test_cases: List[Path] = []

    def add_test_case(self, test_case: Path):
        self.test_cases.append(test_case)

    @property
    def count(self) -> int:
        return len(self.test_cases)


def _is_multi_mode_config(config: Dict[str, Any]) -> bool:
    """Check if a test case config defines multiple modes.

    Multi-mode configs have a 'modes' key with mode definitions and require
    a different test runner (run_mode_transition_test) than standard configs
    (run_test_case).

    Parameters
    ----------
    config : Dict[str, Any]
        Parsed test case configuration

    Returns
    -------
    bool
        True if this is a multi-mode configuration
    """
    return "modes" in config and isinstance(config["modes"], dict)


def discover_test_cases() -> Dict[str, TestCategory]:
    """
    Discover standard (single-mode) test case configurations organized by category.

    Multi-mode configs are excluded because they require a different test runner.
    Use discover_mode_transition_test_cases() for those.

    Returns
    -------
    Dict[str, TestCategory]
        Dictionary mapping category names to TestCategory objects
    """
    categories: Dict[str, TestCategory] = {}

    # Look for test cases in the main test_cases directory
    for test_file in TEST_CASES_DIR.glob("*.json5"):
        try:
            config = load_test_case(test_file)

            if _is_multi_mode_config(config):
                continue

            category_name = config.get("category", "uncategorized")

            if category_name not in categories:
                categories[category_name] = TestCategory(category_name, TEST_CASES_DIR)

            categories[category_name].add_test_case(test_file)

        except Exception as e:
            logging.error(f"Error loading test case {test_file}: {e}")

    # Look for test cases in category subdirectories
    for category_dir in TEST_CASES_DIR.glob("*/"):
        if category_dir.is_dir() and not category_dir.name.startswith("_"):
            category_name = category_dir.name

            if category_name not in categories:
                categories[category_name] = TestCategory(category_name, category_dir)

            for test_file in category_dir.glob("*.json5"):
                try:
                    config = load_test_case(test_file)
                    if _is_multi_mode_config(config):
                        continue
                    categories[category_name].add_test_case(test_file)
                except Exception as e:
                    logging.error(f"Error loading test case {test_file}: {e}")

    return categories


def discover_mode_transition_test_cases() -> List[Path]:
    """
    Discover input-triggered mode transition test cases.

    Returns only multi-mode configs suitable for run_mode_transition_test().
    Configs with dedicated test functions (cooldown, time-based) are excluded
    since they hardcode their own config paths.

    Returns
    -------
    List[Path]
        List of paths to input-triggered mode transition test configs
    """
    test_cases: List[Path] = []

    for test_file in TEST_CASES_DIR.glob("*.json5"):
        try:
            config = load_test_case(test_file)
            if not _is_multi_mode_config(config):
                continue

            # Cooldown configs have dedicated test_cooldown_prevents_transition()
            if "first_transition_mode" in config.get("expected", {}):
                continue

            # Time-based configs have dedicated test_time_based_transition()
            has_time_based_rules = any(
                r.get("transition_type") == "time_based" for r in config.get("transition_rules", [])
            )
            if has_time_based_rules:
                continue

            test_cases.append(test_file)
        except Exception as e:
            logging.error(f"Error loading test case {test_file}: {e}")

    return test_cases


def get_test_cases_by_tags(tags: Optional[List[str]] = None) -> List[Path]:
    """
    Get test cases filtered by tags.

    Parameters
    ----------
    tags : List[str], optional
        List of tags to filter test cases

    Returns
    -------
    List[Path]
        List of test case paths matching the tags
    """
    if not tags:
        # If no tags specified, return all test cases
        return [test_case for category in discover_test_cases().values() for test_case in category.test_cases]

    matching_tests = []
    for category in discover_test_cases().values():
        for test_case in category.test_cases:
            try:
                config = load_test_case(test_case)
                test_tags = config.get("tags", [])
                if any(tag in test_tags for tag in tags):
                    matching_tests.append(test_case)
            except Exception as e:
                logging.error(f"Error checking tags for {test_case}: {e}")

    return matching_tests


@pytest.mark.parametrize("test_case_path", get_test_cases_by_tags())
@pytest.mark.asyncio
@pytest.mark.integration
async def test_from_config(test_case_path: Path):
    """
    Run a test based on a configuration file.

    Parameters
    ----------
    test_case_path : Path
        Path to the test case configuration file
    """
    # Reset mock providers to ensure test isolation
    image_provider = get_image_provider()
    image_provider.reset()
    # Clear any existing images to ensure clean state
    image_provider.test_images = []

    # Reset lidar data as well
    lidar_provider = get_lidar_provider()
    lidar_provider.clear()

    clear_text_provider()
    clear_state_provider()

    # Add a small delay to reduce race conditions between parallel tests
    await asyncio.sleep(0.1)

    # Load and process the test case configuration
    try:
        logging.info(f"Loading test case: {test_case_path}")
        config = load_test_case(test_case_path)

        # Log test information
        logging.info(f"Running test case: {config['name']} ({config.get('category', 'uncategorized')})")
        logging.info(f"Description: {config['description']}")

        # Log expected inputs based on type
        input_section = config.get("input", {})
        if "images" in input_section:
            logging.info(f"Expected images for test: {len(input_section['images'])}")
        if "lidar" in input_section:
            logging.info(f"Expected lidar files for test: {len(input_section['lidar'])}")
        if "asr" in input_section:
            logging.info(f"Expected ASR files for test: {len(input_section['asr'])}")
        if "battery" in input_section:
            logging.info(f"Expected battery files for test: {len(input_section['battery'])}")
        if "odometry" in input_section:
            logging.info(f"Expected odometry files for test: {len(input_section['odometry'])}")
        if "gps" in input_section:
            logging.info(f"Expected GPS files for test: {len(input_section['gps'])}")

        # Run the test case
        results = await run_test_case(config)

        # Evaluate results
        passed, score, message = await evaluate_test_results(results, config["expected"], config["api_key"], config)

        # Log detailed results
        logging.info(f"Test results for {config['name']}:\n{message}")

        # Assert test passed
        assert passed, f"Test case failed: {config['name']} (Score: {score:.2f})\n{message}"

        logging.info(f"test_from_config: Test {config['name']} completed successfully")

    except Exception as e:
        logging.error(f"Error running test case {test_case_path}: {e}")
        raise


# Run a specific test case by name
@pytest.mark.skipif(
    not os.environ.get("TEST_CASE"),
    reason="Skipping specific test case (TEST_CASE is not set)",
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_specific_case():
    """Run a specific test case by name for debugging."""
    test_name = os.environ.get("TEST_CASE", "coco_indoor_detection")

    # Find the test case configuration
    test_case_path = None
    for path in TEST_CASES_DIR.glob("*.json5"):
        config = load_test_case(path)
        if config.get("name") == test_name:
            test_case_path = path
            break

    if not test_case_path:
        pytest.skip(f"Test case not found: {test_name}")

    # Now run the test
    await test_from_config(test_case_path)


# Add cleanup for pytest
def pytest_sessionfinish(session, exitstatus):
    """Clean up after all tests have run."""
    unregister_mock_inputs()


def get_movement_types_for_config(config: Dict[str, Any]) -> set:
    """
    Determine which movement types to use based on the test configuration input types.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration

    Returns
    -------
    set
        The appropriate movement types set for this test case
    """
    input_section = config.get("input", {})

    # Check if this is a LIDAR-based test
    if "lidar" in input_section:
        return LIDAR_MOVE_TYPES

    # ASR-only tests (no images)
    if "asr" in input_section and "images" not in input_section:
        return ASR_MOVE_TYPES

    # State-based tests (battery, odometry, GPS) without images
    if (
        "battery" in input_section or "odometry" in input_section or "gps" in input_section
    ) and "images" not in input_section:
        return STATE_MOVE_TYPES

    # Check if this is an image/VLM-based test
    if "images" in input_section:
        return VLM_MOVE_TYPES

    # Default to VLM types if unclear
    return VLM_MOVE_TYPES


def extract_movement_from_actions(actions: List, movement_types: set) -> str:
    """
    Extract movement command from actions using the appropriate movement types.

    Parameters
    ----------
    actions : List
        List of action commands
    movement_types : set
        Set of valid movement types for this test case

    Returns
    -------
    str
        The extracted movement command or "unknown"
    """
    for command in actions:
        if hasattr(command, "type"):
            if command.type in movement_types:
                return command.type
            elif command.type == "move" and hasattr(command, "value"):
                if command.value in movement_types:
                    return command.value

    return "unknown"


def _setup_mode_transition_mocks(
    cortex: ModeCortexRuntime,
) -> asyncio.Task:  # type: ignore[type-arg]
    """Set up common mocks for mode transition tests.

    Mocks _start_orchestrators (prevents infinite loop) and LLM (returns
    a simple action). Starts the mode transition handler task.

    Parameters
    ----------
    cortex : ModeCortexRuntime
        The runtime instance to mock

    Returns
    -------
    asyncio.Task
        The transition handler task (must be cancelled during cleanup)
    """

    async def noop_start_orchestrators():
        logging.info("_start_orchestrators skipped (test mock)")

    cortex._start_orchestrators = noop_start_orchestrators  # type: ignore[assignment]

    async def mock_llm_ask(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        return CortexOutputModel(actions=[Action(type="move", value="stand still")])

    async def mock_llm_ask_stream(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        """Stream wrapper that yields single result from ask."""
        result = await mock_llm_ask(prompt, messages)
        if result is not None:
            yield result

    cortex.current_config.cortex_llm.ask = mock_llm_ask  # type: ignore[union-attr]
    cortex.current_config.cortex_llm.ask_stream = mock_llm_ask_stream  # type: ignore[union-attr]

    return asyncio.create_task(cortex._handle_mode_transitions())


async def _cleanup_mode_transition_test(
    cortex: ModeCortexRuntime,
    transition_handler_task: asyncio.Task,  # type: ignore[type-arg]
) -> None:
    """Clean up after a mode transition test.

    Parameters
    ----------
    cortex : ModeCortexRuntime
        The runtime instance
    transition_handler_task : asyncio.Task
        The transition handler task to cancel
    """
    transition_handler_task.cancel()
    try:
        await transition_handler_task
    except asyncio.CancelledError:
        pass

    await cleanup_mock_inputs(cortex.current_config.agent_inputs)  # type: ignore[union-attr]
    clear_text_provider()


async def run_mode_transition_test(config: Dict[str, Any]) -> Dict[str, Any]:
    """Run a mode transition integration test.

    This function tests that an input-triggered mode transition works
    correctly by running a single tick of the cortex loop:
    - fuser.fuse() calls formatted_latest_buffer() -> add_mode_transition_input()
    - process_tick() detects keyword and schedules transition
    - _handle_mode_transitions() executes the transition asynchronously

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration with modes and transition_rules

    Returns
    -------
    Dict[str, Any]
        Results containing initial_mode and final_mode
    """
    load_test_asr_data(config)

    mode_system_config = build_multi_mode_config(config)
    cortex = ModeCortexRuntime(mode_system_config, "test_config", hot_reload=False)
    default_mode = config.get("default_mode", "calm")
    await cortex._initialize_mode(default_mode)

    assert cortex.current_config is not None

    initial_mode = cortex.mode_manager.state.current_mode
    initial_prompt = cortex.current_config.system_prompt_base
    logging.info(f"Mode transition test: initial_mode={initial_mode}")

    transition_handler_task = _setup_mode_transition_mocks(cortex)

    await initialize_mock_inputs(cortex.current_config.agent_inputs)
    await cortex._tick(cortex._cortex_loop_generation)

    # If a transition was scheduled, wait for the handler to process it
    if cortex._mode_transition_event.is_set():
        await asyncio.sleep(0.5)

    final_mode = cortex.mode_manager.state.current_mode
    final_prompt = cortex.current_config.system_prompt_base if cortex.current_config else None
    logging.info(f"Mode transition test: final_mode={final_mode}")

    await _cleanup_mode_transition_test(cortex, transition_handler_task)

    return {
        "initial_mode": initial_mode,
        "final_mode": final_mode,
        "initial_prompt": initial_prompt,
        "final_prompt": final_prompt,
    }


@pytest.mark.parametrize("test_case_path", discover_mode_transition_test_cases())
@pytest.mark.asyncio
@pytest.mark.integration
async def test_mode_transition(test_case_path: Path):
    """Test mode transitions discovered from test_cases/.

    Handles both positive (transition expected) and negative (no transition)
    cases based on the expected initial and final modes in the config.
    """
    clear_text_provider()

    config = load_test_case(test_case_path)
    test_name = config["name"]

    logging.info(f"Running mode transition test: {test_name}")

    results = await run_mode_transition_test(config)

    expected_initial = config["expected"]["initial_mode"]
    expected_final = config["expected"]["final_mode"]

    assert results["initial_mode"] == expected_initial, (
        f"Initial mode mismatch: got {results['initial_mode']}, " f"expected {expected_initial}"
    )
    assert results["final_mode"] == expected_final, (
        f"Final mode mismatch: got {results['final_mode']}, " f"expected {expected_final}"
    )

    # Only verify config reinitialization when a transition actually occurred
    if expected_initial != expected_final:
        expected_final_prompt = config["modes"][expected_final]["system_prompt_base"]
        assert results["final_prompt"] == expected_final_prompt, (
            f"Runtime config not reinitialized: prompt is '{results['final_prompt']}', "
            f"expected '{expected_final_prompt}'"
        )
        assert (
            results["final_prompt"] != results["initial_prompt"]
        ), "System prompt did not change after mode transition"
    else:
        assert (
            results["final_prompt"] == results["initial_prompt"]
        ), "System prompt changed when no transition was expected"


async def run_time_based_transition_test(config: Dict[str, Any]) -> Dict[str, Any]:
    """Run a time-based mode transition integration test.

    Initializes the runtime and waits for the mode timeout to expire,
    then runs a tick to trigger the time-based transition.

    Parameters
    ----------
    config : Dict[str, Any]
        Test case configuration with modes having timeout_seconds

    Returns
    -------
    Dict[str, Any]
        Results containing initial_mode and final_mode
    """
    load_test_asr_data(config)

    mode_system_config = build_multi_mode_config(config)
    cortex = ModeCortexRuntime(mode_system_config, "test_config", hot_reload=False)
    default_mode = config.get("default_mode", "patrol")
    await cortex._initialize_mode(default_mode)

    assert cortex.current_config is not None

    initial_mode = cortex.mode_manager.state.current_mode
    logging.info(f"Time-based transition test: initial_mode={initial_mode}")

    transition_handler_task = _setup_mode_transition_mocks(cortex)

    # Wait for the mode timeout to expire
    timeout = cortex.mode_config.modes[default_mode].timeout_seconds or 0.1
    await asyncio.sleep(timeout + 0.1)

    # Initialize inputs and run tick - process_tick() will detect timeout
    await initialize_mock_inputs(cortex.current_config.agent_inputs)
    await cortex._tick(cortex._cortex_loop_generation)

    if cortex._mode_transition_event.is_set():
        await asyncio.sleep(0.5)

    final_mode = cortex.mode_manager.state.current_mode
    logging.info(f"Time-based transition test: final_mode={final_mode}")

    await _cleanup_mode_transition_test(cortex, transition_handler_task)

    return {"initial_mode": initial_mode, "final_mode": final_mode}


@pytest.mark.asyncio
@pytest.mark.integration
async def test_time_based_transition():
    """Test time-based mode transition after timeout expires."""
    clear_text_provider()

    config = load_test_case(TEST_CASES_DIR / "mode_time_based_test.json5")
    logging.info(f"Running time-based transition test: {config['name']}")

    results = await run_time_based_transition_test(config)

    assert results["initial_mode"] == config["expected"]["initial_mode"], (
        f"Initial mode mismatch: got {results['initial_mode']}, " f"expected {config['expected']['initial_mode']}"
    )
    assert results["final_mode"] == config["expected"]["final_mode"], (
        f"Final mode mismatch: got {results['final_mode']}, " f"expected {config['expected']['final_mode']}"
    )


@pytest.mark.asyncio
@pytest.mark.integration
async def test_cooldown_prevents_transition():
    """Test that cooldown prevents repeated transitions."""
    clear_text_provider()

    config = load_test_case(TEST_CASES_DIR / "mode_cooldown_test.json5")
    logging.info(f"Running cooldown test: {config['name']}")

    load_test_asr_data(config)

    mode_system_config = build_multi_mode_config(config)
    cortex = ModeCortexRuntime(mode_system_config, "test_config", hot_reload=False)
    await cortex._initialize_mode("calm")

    assert cortex.current_config is not None

    transition_handler_task = _setup_mode_transition_mocks(cortex)

    # First transition: calm -> alert (should succeed)
    await initialize_mock_inputs(cortex.current_config.agent_inputs)
    await cortex._tick(cortex._cortex_loop_generation)

    if cortex._mode_transition_event.is_set():
        await asyncio.sleep(0.5)

    first_mode = cortex.mode_manager.state.current_mode
    logging.info(f"Cooldown test: after first transition: {first_mode}")
    assert first_mode == config["expected"]["first_transition_mode"], (
        f"First transition failed: got {first_mode}, " f"expected {config['expected']['first_transition_mode']}"
    )

    # Manually reset mode back to calm to test cooldown
    cortex.mode_manager.state.current_mode = "calm"
    cortex.mode_manager.state.mode_start_time = time.time()

    # Reload ASR data and reinitialize inputs for second attempt.
    # Re-apply LLM mock since _initialize_mode creates a new config.
    load_test_asr_data(config)
    await cortex._initialize_mode("calm")

    async def mock_llm_ask(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        return CortexOutputModel(actions=[Action(type="move", value="stand still")])

    async def mock_llm_ask_stream(prompt: str, messages: Optional[List[Dict[str, str]]] = None):
        result = await mock_llm_ask(prompt, messages)
        if result is not None:
            yield result

    cortex.current_config.cortex_llm.ask = mock_llm_ask  # type: ignore[union-attr]
    cortex.current_config.cortex_llm.ask_stream = mock_llm_ask_stream  # type: ignore[union-attr]

    await initialize_mock_inputs(cortex.current_config.agent_inputs)
    await cortex._tick(cortex._cortex_loop_generation)

    if cortex._mode_transition_event.is_set():
        await asyncio.sleep(0.5)

    second_mode = cortex.mode_manager.state.current_mode
    logging.info(f"Cooldown test: after second attempt: {second_mode}")

    # Should still be calm because cooldown (60s) hasn't expired
    assert second_mode == config["expected"]["second_transition_mode"], (
        f"Cooldown failed: mode changed to {second_mode}, " f"expected {config['expected']['second_transition_mode']}"
    )

    await _cleanup_mode_transition_test(cortex, transition_handler_task)
    clear_text_provider()
