"""AI-Powered Test Generation for autonomous debugging.

This module generates tests using LLMs, leveraging code analysis
to create comprehensive, edge-case-aware test suites.
"""

from __future__ import annotations

import ast
import hashlib
import json
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

logger = logging.getLogger(__name__)

try:
    import anthropic
    HAS_ANTHROPIC = True
except ImportError:
    HAS_ANTHROPIC = False

try:
    import openai
    HAS_OPENAI = True
except ImportError:
    HAS_OPENAI = False


@dataclass
class FunctionInfo:
    """Information about a function to generate tests for."""

    name: str
    file_path: str
    line_number: int
    source: str
    signature: str
    docstring: Optional[str]
    decorators: List[str]
    is_async: bool
    is_method: bool
    class_name: Optional[str]
    complexity: int  # Cyclomatic complexity estimate
    dependencies: List[str]  # Imported dependencies used

    @property
    def qualified_name(self) -> str:
        """Get fully qualified name."""
        if self.class_name:
            return f"{self.class_name}.{self.name}"
        return self.name

    @property
    def id(self) -> str:
        """Unique identifier."""
        content = f"{self.file_path}:{self.qualified_name}".encode()
        return hashlib.sha256(content).hexdigest()[:12]


@dataclass
class TestCase:
    """A generated test case."""

    name: str
    description: str
    test_code: str
    target_function: str
    strategy: str
    confidence: float
    fixtures: List[str]
    mocks: List[str]
    assertions: List[str]

    @property
    def id(self) -> str:
        """Unique identifier."""
        content = f"{self.name}:{self.test_code}".encode()
        return hashlib.sha256(content).hexdigest()[:12]


@dataclass
class TestSuite:
    """A complete test suite for a module."""

    module_name: str
    file_path: str
    imports: List[str]
    fixtures: List[str]
    test_cases: List[TestCase]
    setup_code: Optional[str]
    teardown_code: Optional[str]

    def to_file_content(self) -> str:
        """Generate the complete test file content."""
        lines = [
            '"""Auto-generated tests by Hikaflow AI Test Generator."""',
            "",
            "import pytest",
            "from unittest.mock import Mock, patch, MagicMock",
            "",
        ]

        # Add imports
        for imp in self.imports:
            lines.append(imp)

        lines.append("")
        lines.append("")

        # Add fixtures
        for fixture in self.fixtures:
            lines.append(fixture)
            lines.append("")

        # Add setup if present
        if self.setup_code:
            lines.append(self.setup_code)
            lines.append("")

        # Add test cases
        for test in self.test_cases:
            lines.append(f"# {test.description}")
            lines.append(f"# Strategy: {test.strategy} (confidence: {test.confidence:.0%})")
            lines.append(test.test_code)
            lines.append("")
            lines.append("")

        # Add teardown if present
        if self.teardown_code:
            lines.append(self.teardown_code)

        return "\n".join(lines)


@dataclass
class CodeAnalysis:
    """Analysis of source code for test generation."""

    file_path: str
    module_name: str
    imports: List[str]
    functions: List[FunctionInfo]
    classes: List[Dict[str, Any]]
    global_vars: List[str]

    @property
    def total_complexity(self) -> int:
        """Total complexity of all functions."""
        return sum(f.complexity for f in self.functions)

    @property
    def testable_functions(self) -> List[FunctionInfo]:
        """Functions that should have tests (deduplicated by qualified name)."""
        seen: set[str] = set()
        result: List[FunctionInfo] = []
        for f in self.functions:
            if (not f.name.startswith("_")) or (f.name.startswith("__") and f.name.endswith("__")):
                if f.qualified_name not in seen:
                    seen.add(f.qualified_name)
                    result.append(f)
        return result


# Test generation strategies
GENERATION_STRATEGIES = [
    ("happy_path", "Generate tests for the main successful use case"),
    ("edge_cases", "Generate tests for boundary conditions and edge cases"),
    ("error_cases", "Generate tests for error handling and invalid inputs"),
    ("null_checks", "Generate tests for None/null handling"),
    ("type_variants", "Generate tests with different input types"),
    ("integration", "Generate tests for function interactions"),
]


# Prompt templates
ANALYZE_CODE_PROMPT = """Analyze this Python code and identify testable elements:

```python
{source_code}
```

Identify:
1. All functions and methods that need testing
2. Edge cases and boundary conditions
3. Error handling paths
4. Dependencies that may need mocking
5. Integration points with external systems

Return JSON:
```json
{{
  "functions": [
    {{
      "name": "function_name",
      "purpose": "what it does",
      "edge_cases": ["list of edge cases"],
      "error_conditions": ["possible error conditions"],
      "dependencies": ["external dependencies to mock"],
      "priority": 1-5
    }}
  ],
  "fixtures_needed": ["list of pytest fixtures that would help"],
  "mocking_strategy": "overall mocking approach"
}}
```
"""

GENERATE_TESTS_PROMPT = """Generate comprehensive pytest tests for this Python function:

## Function to Test
```python
{function_source}
```

## Full Module Context
File: {file_path}
Imports:
```python
{imports}
```

## Analysis
- Purpose: {purpose}
- Edge cases to cover: {edge_cases}
- Error conditions to handle: {error_conditions}
- Dependencies to mock: {dependencies}

## Strategy
Focus on: {strategy}
{strategy_description}

## Requirements
1. Generate {num_tests} test functions
2. Use pytest style with clear names (test_function_scenario)
3. Include docstrings explaining what each test verifies
4. Use appropriate fixtures and mocking
5. Cover both positive and negative cases
6. Assert specific values, not just "no exception"
7. Keep tests independent and deterministic

## Output Format
Return JSON with generated tests:
```json
{{
  "tests": [
    {{
      "name": "test_function_name_scenario",
      "description": "What this test verifies",
      "code": "def test_function_name_scenario():\\n    # test code here\\n    assert result == expected",
      "fixtures": ["fixture names used"],
      "mocks": ["things being mocked"],
      "assertions": ["what is being asserted"],
      "confidence": 0.9
    }}
  ],
  "shared_fixtures": [
    {{
      "name": "fixture_name",
      "code": "@pytest.fixture\\ndef fixture_name():\\n    # fixture code"
    }}
  ],
  "imports": ["additional imports needed"]
}}
```
"""


# M-03: Reuse CostTracker from hypothesis module instead of maintaining a duplicate
from autodebug.autonomous.hypothesis import CostTracker  # noqa: E402


def _estimate_complexity(node: ast.FunctionDef) -> int:
    """Estimate cyclomatic complexity of a function."""
    complexity = 1  # Base complexity

    for child in ast.walk(node):
        if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
            complexity += 1
        elif isinstance(child, ast.BoolOp):
            complexity += len(child.values) - 1
        elif isinstance(child, ast.comprehension):
            complexity += 1
            if child.ifs:
                complexity += len(child.ifs)

    return complexity


def _extract_dependencies(node: ast.FunctionDef, imports: List[str]) -> List[str]:
    """Extract dependencies used in a function."""
    used_names: Set[str] = set()

    for child in ast.walk(node):
        if isinstance(child, ast.Name):
            used_names.add(child.id)
        elif isinstance(child, ast.Attribute):
            if isinstance(child.value, ast.Name):
                used_names.add(child.value.id)

    # Match against imports
    dependencies = []
    import_names = set()
    for imp in imports:
        if imp.startswith("from "):
            match = re.search(r"from\s+(\S+)\s+import\s+(.+)", imp)
            if match:
                module = match.group(1)
                names = [n.strip().split(" as ")[0] for n in match.group(2).split(",")]
                import_names.update(names)
                for name in names:
                    if name in used_names:
                        dependencies.append(f"{module}.{name}")
        elif imp.startswith("import "):
            match = re.search(r"import\s+(.+)", imp)
            if match:
                names = [n.strip().split(" as ")[0] for n in match.group(1).split(",")]
                import_names.update(names)
                for name in names:
                    if name in used_names:
                        dependencies.append(name)

    return dependencies


def analyze_source_file(file_path: Path) -> CodeAnalysis:
    """Analyze a Python source file for test generation.

    Args:
        file_path: Path to the Python file

    Returns:
        CodeAnalysis with functions, classes, and imports
    """
    source = file_path.read_text()
    tree = ast.parse(source)

    imports: List[str] = []
    functions: List[FunctionInfo] = []
    classes: List[Dict[str, Any]] = []
    global_vars: List[str] = []

    # Extract imports
    for node in ast.iter_child_nodes(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                name = alias.asname or alias.name
                imports.append(f"import {alias.name}" + (f" as {alias.asname}" if alias.asname else ""))
        elif isinstance(node, ast.ImportFrom):
            names = ", ".join(
                f"{a.name}" + (f" as {a.asname}" if a.asname else "")
                for a in node.names
            )
            imports.append(f"from {node.module} import {names}")

    # Extract functions and classes
    for node in ast.iter_child_nodes(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            # Get function signature
            args = []
            for arg in node.args.args:
                arg_str = arg.arg
                if arg.annotation:
                    arg_str += f": {ast.unparse(arg.annotation)}"
                args.append(arg_str)

            signature = f"def {node.name}({', '.join(args)})"
            if node.returns:
                signature += f" -> {ast.unparse(node.returns)}"

            # Get docstring
            docstring = ast.get_docstring(node)

            # Get decorators
            decorators = [ast.unparse(d) for d in node.decorator_list]

            functions.append(FunctionInfo(
                name=node.name,
                file_path=str(file_path),
                line_number=node.lineno,
                source=ast.get_source_segment(source, node) or "",
                signature=signature,
                docstring=docstring,
                decorators=decorators,
                is_async=isinstance(node, ast.AsyncFunctionDef),
                is_method=False,
                class_name=None,
                complexity=_estimate_complexity(node),
                dependencies=_extract_dependencies(node, imports),
            ))

        elif isinstance(node, ast.ClassDef):
            class_info = {
                "name": node.name,
                "line_number": node.lineno,
                "methods": [],
                "decorators": [ast.unparse(d) for d in node.decorator_list],
            }

            for item in node.body:
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    # Get method signature
                    args = []
                    for arg in item.args.args:
                        arg_str = arg.arg
                        if arg.annotation:
                            arg_str += f": {ast.unparse(arg.annotation)}"
                        args.append(arg_str)

                    signature = f"def {item.name}({', '.join(args)})"
                    if item.returns:
                        signature += f" -> {ast.unparse(item.returns)}"

                    method_info = FunctionInfo(
                        name=item.name,
                        file_path=str(file_path),
                        line_number=item.lineno,
                        source=ast.get_source_segment(source, item) or "",
                        signature=signature,
                        docstring=ast.get_docstring(item),
                        decorators=[ast.unparse(d) for d in item.decorator_list],
                        is_async=isinstance(item, ast.AsyncFunctionDef),
                        is_method=True,
                        class_name=node.name,
                        complexity=_estimate_complexity(item),
                        dependencies=_extract_dependencies(item, imports),
                    )

                    class_info["methods"].append(method_info)
                    functions.append(method_info)

            classes.append(class_info)

        elif isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name):
                    global_vars.append(target.id)

    module_name = file_path.stem

    return CodeAnalysis(
        file_path=str(file_path),
        module_name=module_name,
        imports=imports,
        functions=functions,
        classes=classes,
        global_vars=global_vars,
    )


def _call_anthropic(
    prompt: str,
    model: str = "claude-sonnet-4-20250514",
    max_tokens: int = 8000,
    cost_tracker: Optional[CostTracker] = None,
) -> str:
    """Call Anthropic API (or DeepSeek/OpenAI-compatible if configured)."""
    # Prefer DeepSeek/OpenAI-compatible path
    try:
        from autodebug.llm_config import get_llm_config, create_openai_client
        config = get_llm_config()
        if config.is_openai_compatible:
            client = create_openai_client(config)
            response = client.chat.completions.create(
                model=config.default_model,
                max_tokens=max_tokens,
                messages=[{"role": "user", "content": prompt}],
            )
            if cost_tracker and response.usage:
                cost_tracker.record(
                    config.default_model,
                    response.usage.prompt_tokens,
                    response.usage.completion_tokens,
                )
            return response.choices[0].message.content
    except (ImportError, ValueError):
        pass

    if not HAS_ANTHROPIC:
        raise ImportError("anthropic package not installed")

    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        raise ValueError("ANTHROPIC_API_KEY environment variable not set")

    client = anthropic.Anthropic(api_key=api_key, timeout=120.0)
    response = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        messages=[{"role": "user", "content": prompt}],
    )

    if cost_tracker:
        cost_tracker.record(
            model,
            response.usage.input_tokens,
            response.usage.output_tokens,
        )

    return response.content[0].text


def _call_openai(
    prompt: str,
    model: str = "gpt-5",
    max_tokens: int = 8000,
    cost_tracker: Optional[CostTracker] = None,
) -> str:
    """Call OpenAI API."""
    if not HAS_OPENAI:
        raise ImportError("openai package not installed")

    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        raise ValueError("OPENAI_API_KEY environment variable not set")

    client = openai.OpenAI(api_key=api_key, timeout=120.0)
    # GPT-5+ requires max_completion_tokens instead of max_tokens
    token_kwarg = (
        {"max_completion_tokens": max_tokens}
        if "gpt-5" in model or "o1" in model or "o3" in model
        else {"max_tokens": max_tokens}
    )
    response = client.chat.completions.create(
        model=model,
        **token_kwarg,
        messages=[{"role": "user", "content": prompt}],
    )

    if cost_tracker and response.usage:
        cost_tracker.record(
            model,
            response.usage.prompt_tokens,
            response.usage.completion_tokens,
        )

    return response.choices[0].message.content


def _call_llm(
    prompt: str,
    cost_tracker: Optional[CostTracker] = None,
) -> str:
    """Call LLM with fallback between providers."""
    has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
    has_openai = bool(os.environ.get("OPENAI_API_KEY"))

    if has_anthropic:
        try:
            return _call_anthropic(prompt, cost_tracker=cost_tracker)
        except Exception as e:
            if has_openai:
                logger.info("Anthropic failed (%s), falling back to OpenAI...", e)
            else:
                raise

    if has_openai:
        return _call_openai(prompt, cost_tracker=cost_tracker)

    raise ValueError("No API key available (ANTHROPIC_API_KEY or OPENAI_API_KEY)")


def _parse_json_response(response: str) -> Dict[str, Any]:
    """Parse JSON from LLM response."""
    # Extract JSON from markdown code block
    json_match = re.search(r"```json\s*(.*?)\s*```", response, re.DOTALL)
    if json_match:
        json_str = json_match.group(1)
    else:
        # Try to find raw JSON
        json_match = re.search(r"\{.*\}", response, re.DOTALL)
        if json_match:
            json_str = json_match.group(0)
        else:
            return {}

    try:
        return json.loads(json_str)
    except json.JSONDecodeError:
        return {}


def analyze_for_testing(
    analysis: CodeAnalysis,
    cost_tracker: Optional[CostTracker] = None,
) -> Dict[str, Any]:
    """Use LLM to analyze code for test generation.

    Args:
        analysis: CodeAnalysis from analyze_source_file
        cost_tracker: Optional cost tracker

    Returns:
        Dict with analysis results
    """
    # Prepare source code summary
    source_parts = []
    for func in analysis.testable_functions[:20]:  # Limit to avoid token limits
        source_parts.append(func.source)

    source_code = "\n\n".join(source_parts)

    prompt = ANALYZE_CODE_PROMPT.format(source_code=source_code)

    response = _call_llm(prompt, cost_tracker)
    return _parse_json_response(response)


def generate_tests_for_function(
    func: FunctionInfo,
    analysis: CodeAnalysis,
    llm_analysis: Dict[str, Any],
    strategy: str = "happy_path",
    num_tests: int = 3,
    cost_tracker: Optional[CostTracker] = None,
) -> List[TestCase]:
    """Generate tests for a specific function.

    Args:
        func: FunctionInfo to generate tests for
        analysis: Full code analysis
        llm_analysis: LLM analysis results
        strategy: Test generation strategy
        num_tests: Number of tests to generate
        cost_tracker: Optional cost tracker

    Returns:
        List of generated TestCase objects
    """
    # Find function-specific analysis
    func_analysis = None
    for f in llm_analysis.get("functions", []):
        if f.get("name") == func.name:
            func_analysis = f
            break

    if not func_analysis:
        func_analysis = {
            "purpose": func.docstring or "No documentation available",
            "edge_cases": [],
            "error_conditions": [],
            "dependencies": func.dependencies,
        }

    strategy_desc = dict(GENERATION_STRATEGIES).get(strategy, "Generate comprehensive tests")

    prompt = GENERATE_TESTS_PROMPT.format(
        function_source=func.source,
        file_path=analysis.file_path,
        imports="\n".join(analysis.imports),
        purpose=func_analysis.get("purpose", ""),
        edge_cases=", ".join(func_analysis.get("edge_cases", [])),
        error_conditions=", ".join(func_analysis.get("error_conditions", [])),
        dependencies=", ".join(func_analysis.get("dependencies", [])),
        strategy=strategy,
        strategy_description=strategy_desc,
        num_tests=num_tests,
    )

    response = _call_llm(prompt, cost_tracker)
    parsed = _parse_json_response(response)

    tests = []
    for t in parsed.get("tests", []):
        tests.append(TestCase(
            name=t.get("name", f"test_{func.name}"),
            description=t.get("description", ""),
            test_code=t.get("code", ""),
            target_function=func.qualified_name,
            strategy=strategy,
            confidence=float(t.get("confidence", 0.7)),
            fixtures=t.get("fixtures", []),
            mocks=t.get("mocks", []),
            assertions=t.get("assertions", []),
        ))

    return tests


def generate_tests(
    file_path: Path,
    strategies: Optional[List[str]] = None,
    target_coverage: int = 80,
    max_tests_per_function: int = 5,
    output_path: Optional[Path] = None,
    cost_tracker: Optional[CostTracker] = None,
    progress_callback: Optional[callable] = None,
) -> TestSuite:
    """Generate a complete test suite for a Python file.

    Args:
        file_path: Path to the Python file to test
        strategies: List of strategies to use (default: all)
        target_coverage: Target coverage percentage
        max_tests_per_function: Max tests per function
        output_path: Where to save the generated tests
        cost_tracker: Optional cost tracker
        progress_callback: Callback for progress updates

    Returns:
        TestSuite with all generated tests
    """
    if strategies is None:
        strategies = ["happy_path", "edge_cases", "error_cases"]

    if cost_tracker is None:
        cost_tracker = CostTracker()

    # Analyze source file
    analysis = analyze_source_file(file_path)

    if progress_callback:
        progress_callback(0, len(analysis.testable_functions), "Analyzing code...")

    # Get LLM analysis
    llm_analysis = analyze_for_testing(analysis, cost_tracker)

    # Generate tests for each function
    all_tests: List[TestCase] = []
    all_fixtures: List[str] = []
    all_imports: Set[str] = set()

    testable = analysis.testable_functions
    for i, func in enumerate(testable):
        if progress_callback:
            progress_callback(i + 1, len(testable), f"Generating tests for {func.name}...")

        # Calculate tests per strategy
        tests_per_strategy = max(1, max_tests_per_function // len(strategies))

        for strategy in strategies:
            try:
                tests = generate_tests_for_function(
                    func,
                    analysis,
                    llm_analysis,
                    strategy=strategy,
                    num_tests=tests_per_strategy,
                    cost_tracker=cost_tracker,
                )
                all_tests.extend(tests)
            except Exception as e:
                print(f"Error generating {strategy} tests for {func.name}: {e}")
                continue

    # Collect unique fixtures and imports from LLM analysis
    for fixture in llm_analysis.get("fixtures_needed", []):
        # Generate fixture code
        fixture_code = f"""@pytest.fixture
def {fixture}():
    # TODO: Implement fixture
    pass"""
        all_fixtures.append(fixture_code)

    # Build import statements using dotted module path
    module_name = analysis.module_name
    # Try to build a proper dotted module path from file_path
    dotted_module = module_name
    try:
        # Walk up from file_path looking for a package root (dir without __init__.py)
        parts = []
        current = file_path
        parts.append(current.stem)
        parent = current.parent
        while (parent / "__init__.py").exists():
            parts.append(parent.name)
            parent = parent.parent
        if len(parts) > 1:
            dotted_module = ".".join(reversed(parts))
    except Exception:
        pass  # Fall back to bare module name

    # Import from the module being tested
    all_imports.add(f"from {dotted_module} import *")

    # Create test suite
    suite = TestSuite(
        module_name=f"test_{module_name}",
        file_path=str(output_path or f"test_{module_name}.py"),
        imports=sorted(list(all_imports)),
        fixtures=all_fixtures,
        test_cases=all_tests,
        setup_code=None,
        teardown_code=None,
    )

    # Save if output path provided
    if output_path:
        output_path.write_text(suite.to_file_content())

    return suite


def format_generation_report(suite: TestSuite, cost_tracker: CostTracker) -> str:
    """Format test generation results as a report.

    Args:
        suite: Generated TestSuite
        cost_tracker: Cost tracker with usage info

    Returns:
        Formatted report string
    """
    lines = [
        "",
        "Test Generation Report",
        "=" * 50,
        "",
        f"Module: {suite.module_name}",
        f"Total tests generated: {len(suite.test_cases)}",
        f"Fixtures created: {len(suite.fixtures)}",
        "",
    ]

    # Group tests by function
    by_function: Dict[str, List[TestCase]] = {}
    for test in suite.test_cases:
        if test.target_function not in by_function:
            by_function[test.target_function] = []
        by_function[test.target_function].append(test)

    lines.append("Tests by function:")
    for func_name, tests in sorted(by_function.items()):
        lines.append(f"  {func_name}: {len(tests)} tests")
        for test in tests:
            lines.append(f"    - {test.name} ({test.strategy}, {test.confidence:.0%})")

    lines.append("")
    lines.append("Strategy distribution:")
    by_strategy: Dict[str, int] = {}
    for test in suite.test_cases:
        by_strategy[test.strategy] = by_strategy.get(test.strategy, 0) + 1
    for strategy, count in sorted(by_strategy.items()):
        lines.append(f"  {strategy}: {count} tests")

    lines.append("")
    lines.append(f"LLM cost: {cost_tracker}")

    if suite.file_path:
        lines.append("")
        lines.append(f"Output saved to: {suite.file_path}")

    return "\n".join(lines)


def validate_generated_tests(
    suite: TestSuite,
    source_dir: Path,
    timeout: int = 60,
) -> Dict[str, Any]:
    """Validate generated tests by running them.

    Args:
        suite: TestSuite to validate
        source_dir: Directory containing source code
        timeout: Timeout per test in seconds

    Returns:
        Validation results
    """
    import subprocess
    import tempfile

    results = {
        "passed": 0,
        "failed": 0,
        "errors": 0,
        "details": [],
    }

    # Create temporary test file
    with tempfile.NamedTemporaryFile(
        mode="w",
        suffix=".py",
        delete=False,
        dir=str(source_dir),
    ) as f:
        f.write(suite.to_file_content())
        test_file = Path(f.name)

    try:
        # M-04: Run LLM-generated test code with network isolation.
        # These tests are AI-generated and could contain arbitrary code,
        # so we apply the same safety constraints as run_harness():
        # --network=none prevents exfiltration, resource limits prevent DoS.
        # If Docker is unavailable, fall back to direct execution with a warning.
        import shutil

        use_docker = shutil.which("docker") is not None
        if use_docker:
            proc = subprocess.run(
                [
                    "docker", "run", "--rm",
                    "--network=none",
                    "--memory=512m",
                    "--cpus=1",
                    "--pids-limit=256",
                    "--security-opt=no-new-privileges",
                    "--read-only",
                    "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
                    "-v", f"{source_dir}:/workspace:ro",
                    "-v", f"{test_file}:/workspace/{test_file.name}:ro",
                    "-w", "/workspace",
                    "python:3.11-slim",
                    "python", "-m", "pytest",
                    f"/workspace/{test_file.name}",
                    "-v", "--tb=short",
                ],
                capture_output=True,
                text=True,
                timeout=timeout,
            )
        else:
            import logging as _logging
            _logging.getLogger(__name__).warning(
                "Docker unavailable: running LLM-generated tests directly on host. "
                "Install Docker for sandboxed execution."
            )
            proc = subprocess.run(
                ["python", "-m", "pytest", str(test_file), "-v", "--tb=short"],
                capture_output=True,
                text=True,
                timeout=timeout,
                cwd=str(source_dir),
            )

        # Parse output
        for line in proc.stdout.splitlines():
            if " PASSED" in line:
                results["passed"] += 1
            elif " FAILED" in line:
                results["failed"] += 1
            elif " ERROR" in line:
                results["errors"] += 1

        results["output"] = proc.stdout
        results["exit_code"] = proc.returncode

    except subprocess.TimeoutExpired:
        results["errors"] = len(suite.test_cases)
        results["output"] = "Timeout"
    except Exception as e:
        results["errors"] = len(suite.test_cases)
        results["output"] = str(e)
    finally:
        test_file.unlink(missing_ok=True)

    return results
