"""Test Case Minimization using Delta Debugging.

This module implements delta debugging for minimizing failing tests to their
essential elements. It parses tests into AST, identifies removable parts,
and uses binary search to find the minimal failing subset.
"""

from __future__ import annotations

import ast
import logging
import subprocess
import tempfile
import textwrap
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union

logger = logging.getLogger(__name__)


# =============================================================================
# Data Classes
# =============================================================================


@dataclass
class MinimizedTest:
    """Result of test minimization."""

    essential_code: str
    """The minimized test code containing only essential elements."""

    setup_code: str
    """Setup code required for the test to run."""

    assertions: List[str]
    """List of essential assertion statements."""

    reduction_ratio: float
    """Ratio of code reduced (0.0 = no reduction, 1.0 = fully reduced)."""

    original_lines: int = 0
    """Number of lines in the original test."""

    minimized_lines: int = 0
    """Number of lines in the minimized test."""

    removed_statements: List[str] = field(default_factory=list)
    """Statements that were removed during minimization."""

    iterations: int = 0
    """Number of delta debugging iterations performed."""


@dataclass
class TestElement:
    """An element of a test that can be potentially removed."""

    node: ast.AST
    """The AST node representing this element."""

    element_type: str
    """Type of element: 'import', 'assignment', 'call', 'assertion', 'setup', 'other'."""

    code: str
    """String representation of this element."""

    line_start: int
    """Starting line number."""

    line_end: int
    """Ending line number."""

    is_essential: bool = False
    """Whether this element is essential (cannot be removed)."""

    dependencies: Set[str] = field(default_factory=set)
    """Names this element depends on."""

    provides: Set[str] = field(default_factory=set)
    """Names this element provides/defines."""


@dataclass
class ParsedTest:
    """A parsed test with identified components."""

    source: str
    """Original test source code."""

    tree: ast.Module
    """Parsed AST module."""

    function_node: Optional[ast.FunctionDef]
    """The test function AST node."""

    elements: List[TestElement]
    """All identified test elements."""

    imports: List[TestElement]
    """Import statements."""

    setup_elements: List[TestElement]
    """Setup/fixture elements."""

    assertions: List[TestElement]
    """Assertion statements."""


# =============================================================================
# AST Utilities
# =============================================================================


class ASTAnalyzer(ast.NodeVisitor):
    """Analyze AST nodes for dependencies and provided names."""

    def __init__(self):
        self.used_names: Set[str] = set()
        self.defined_names: Set[str] = set()

    def visit_Name(self, node: ast.Name) -> None:
        """Track name usage."""
        if isinstance(node.ctx, ast.Load):
            self.used_names.add(node.id)
        elif isinstance(node.ctx, (ast.Store, ast.Del)):
            self.defined_names.add(node.id)
        self.generic_visit(node)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Track function definitions."""
        self.defined_names.add(node.name)
        for arg in node.args.args:
            self.defined_names.add(arg.arg)
        self.generic_visit(node)

    def visit_Import(self, node: ast.Import) -> None:
        """Track imports."""
        for alias in node.names:
            name = alias.asname if alias.asname else alias.name
            self.defined_names.add(name.split(".")[0])

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        """Track from imports."""
        for alias in node.names:
            name = alias.asname if alias.asname else alias.name
            self.defined_names.add(name)

    def analyze(self, node: ast.AST) -> Tuple[Set[str], Set[str]]:
        """Analyze a node and return (used_names, defined_names)."""
        self.used_names = set()
        self.defined_names = set()
        self.visit(node)
        return self.used_names, self.defined_names


def get_node_source(node: ast.AST, source_lines: List[str]) -> str:
    """Extract source code for an AST node.

    Args:
        node: AST node to extract source for.
        source_lines: Lines of source code.

    Returns:
        Source code string for the node.
    """
    if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
        return ""

    start_line = node.lineno - 1
    end_line = node.end_lineno if node.end_lineno else node.lineno

    if start_line < 0 or end_line > len(source_lines):
        return ""

    lines = source_lines[start_line:end_line]
    if not lines:
        return ""

    return "\n".join(lines)


def is_assertion(node: ast.AST) -> bool:
    """Check if a node is an assertion statement.

    Args:
        node: AST node to check.

    Returns:
        True if node is an assertion.
    """
    if isinstance(node, ast.Assert):
        return True

    if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
        func = node.value.func
        if isinstance(func, ast.Attribute):
            # Check for self.assert*, self.assertEqual, etc.
            if func.attr.startswith("assert"):
                return True
        elif isinstance(func, ast.Name):
            # Check for assert_*, pytest assertions
            if func.id.startswith("assert"):
                return True

    return False


def is_setup_call(node: ast.AST) -> bool:
    """Check if a node is likely a setup/fixture call.

    Args:
        node: AST node to check.

    Returns:
        True if node appears to be setup code.
    """
    if not isinstance(node, ast.Expr):
        return False

    if not isinstance(node.value, ast.Call):
        return False

    func = node.value.func
    setup_names = {
        "setup",
        "setUp",
        "setUpClass",
        "fixture",
        "mock",
        "patch",
        "monkeypatch",
        "mocker",
        "configure",
        "initialize",
        "prepare",
        "create",
    }

    if isinstance(func, ast.Name):
        return func.id.lower() in setup_names or func.id.lower().startswith("setup")
    elif isinstance(func, ast.Attribute):
        return func.attr.lower() in setup_names or func.attr.lower().startswith("setup")

    return False


# =============================================================================
# Test Minimizer
# =============================================================================


class TestMinimizer:
    """Minimize failing tests using delta debugging.

    This class implements the delta debugging algorithm to reduce test cases
    to their minimal failing subset. It:
    1. Parses tests into AST and identifies removable parts
    2. Uses binary search to find minimal failing subset
    3. Removes irrelevant setup code
    4. Extracts only relevant assertions

    Example:
        minimizer = TestMinimizer()
        result = minimizer.minimize_failing_test(
            test_code,
            lambda code: run_test(code) == "fail"
        )
        print(f"Reduced by {result.reduction_ratio:.1%}")
    """

    def __init__(
        self,
        preserve_imports: bool = True,
        preserve_fixtures: bool = True,
        max_iterations: int = 100,
        timeout: int = 30,
    ):
        """Initialize test minimizer.

        Args:
            preserve_imports: Always keep import statements.
            preserve_fixtures: Always keep fixture/setup code.
            max_iterations: Maximum delta debugging iterations.
            timeout: Timeout in seconds for test execution.
        """
        self.preserve_imports = preserve_imports
        self.preserve_fixtures = preserve_fixtures
        self.max_iterations = max_iterations
        self.timeout = timeout
        self._analyzer = ASTAnalyzer()

    def minimize_failing_test(
        self,
        test_code: str,
        failure_predicate: Callable[[str], bool],
        test_name: Optional[str] = None,
    ) -> MinimizedTest:
        """Minimize a failing test to essential elements.

        Uses delta debugging to find the minimal subset of test code
        that still reproduces the failure.

        Args:
            test_code: The original test source code.
            failure_predicate: Function that returns True if test still fails.
                Takes the test code string as input.
            test_name: Optional name of the test function to minimize.

        Returns:
            MinimizedTest with the minimized code and metadata.

        Raises:
            SyntaxError: If the test code cannot be parsed.
            ValueError: If no failing subset can be found.
        """
        # Parse the test
        try:
            parsed = self._parse_test(test_code, test_name)
        except SyntaxError as e:
            logger.error(f"Failed to parse test code: {e}")
            raise

        # Verify original fails
        if not failure_predicate(test_code):
            raise ValueError("Original test does not fail - cannot minimize")

        original_lines = len(test_code.splitlines())

        # Extract elements that can be removed
        removable_elements = self._get_removable_elements(parsed)

        if not removable_elements:
            # Nothing to remove
            return MinimizedTest(
                essential_code=test_code,
                setup_code=self._extract_setup_code(parsed),
                assertions=self._format_assertions(parsed.assertions),
                reduction_ratio=0.0,
                original_lines=original_lines,
                minimized_lines=original_lines,
                iterations=0,
            )

        # Run delta debugging
        minimal_elements, iterations = self.delta_debug(
            removable_elements,
            lambda elems: self._test_with_elements(
                parsed, elems, failure_predicate
            ),
        )

        # Reconstruct minimized test
        minimized_code = self._reconstruct_test(parsed, minimal_elements)
        minimized_lines = len(minimized_code.splitlines())

        # Extract assertions from minimized elements
        minimal_assertions = [
            elem for elem in minimal_elements
            if elem.element_type == "assertion"
        ]

        # Determine what was removed
        removed = [
            elem.code for elem in removable_elements
            if elem not in minimal_elements
        ]

        reduction_ratio = 1.0 - (minimized_lines / original_lines) if original_lines > 0 else 0.0

        return MinimizedTest(
            essential_code=minimized_code,
            setup_code=self._extract_setup_code(parsed),
            assertions=self._format_assertions(minimal_assertions),
            reduction_ratio=max(0.0, min(1.0, reduction_ratio)),
            original_lines=original_lines,
            minimized_lines=minimized_lines,
            removed_statements=removed,
            iterations=iterations,
        )

    def delta_debug(
        self,
        elements: List[TestElement],
        test_fn: Callable[[List[TestElement]], bool],
        granularity: int = 2,
    ) -> Tuple[List[TestElement], int]:
        """Delta debugging algorithm to find minimal failing subset.

        Implements Zeller's delta debugging algorithm (ddmin) adapted
        for test elements.

        Args:
            elements: List of test elements to minimize.
            test_fn: Function that returns True if the subset still fails.
            granularity: Initial granularity for splitting.

        Returns:
            Tuple of (minimal elements, iteration count).
        """
        current = elements.copy()
        n = granularity
        iterations = 0

        while len(current) > 1 and iterations < self.max_iterations:
            iterations += 1

            # Split into n subsets
            subset_size = max(1, len(current) // n)
            subsets = []
            for i in range(0, len(current), subset_size):
                subsets.append(current[i:i + subset_size])

            found_smaller = False

            # Try removing each subset (test complement)
            for i in range(len(subsets)):
                complement = []
                for j, subset in enumerate(subsets):
                    if j != i:
                        complement.extend(subset)

                if complement and test_fn(complement):
                    current = complement
                    n = max(2, n - 1)
                    found_smaller = True
                    logger.debug(
                        f"Delta debug iteration {iterations}: "
                        f"reduced to {len(current)} elements"
                    )
                    break

            if not found_smaller:
                # Try each subset individually
                for subset in subsets:
                    if len(subset) < len(current) and test_fn(subset):
                        current = subset
                        n = 2
                        found_smaller = True
                        logger.debug(
                            f"Delta debug iteration {iterations}: "
                            f"found smaller subset with {len(current)} elements"
                        )
                        break

            if not found_smaller:
                if n >= len(current):
                    break
                n = min(n * 2, len(current))

        return current, iterations

    def extract_essential_assertions(
        self,
        test_code: str,
        failure_predicate: Callable[[str], bool],
    ) -> List[str]:
        """Extract only the assertions essential to reproducing the failure.

        Args:
            test_code: The test source code.
            failure_predicate: Function returning True if test fails.

        Returns:
            List of essential assertion code strings.
        """
        try:
            parsed = self._parse_test(test_code)
        except SyntaxError as e:
            logger.error(f"Failed to parse test: {e}")
            return []

        assertions = parsed.assertions
        if not assertions:
            return []

        # Check if removing assertions still causes failure
        essential = []

        for assertion in assertions:
            # Test without this assertion
            test_assertions = [a for a in assertions if a != assertion]

            if not self._test_with_assertions(parsed, test_assertions, failure_predicate):
                # Removing this assertion made test pass - it's essential
                essential.append(assertion)

        # If all assertions are essential, use delta debug to refine
        if len(essential) == len(assertions) and len(assertions) > 1:
            minimal, _ = self.delta_debug(
                assertions,
                lambda asserts: self._test_with_assertions(
                    parsed, asserts, failure_predicate
                ),
            )
            return self._format_assertions(minimal)

        return self._format_assertions(essential) if essential else self._format_assertions(assertions)

    def remove_irrelevant_setup(
        self,
        test_code: str,
        failure_predicate: Callable[[str], bool],
    ) -> str:
        """Remove setup code not required for the failure.

        Args:
            test_code: The test source code.
            failure_predicate: Function returning True if test fails.

        Returns:
            Test code with irrelevant setup removed.
        """
        try:
            parsed = self._parse_test(test_code)
        except SyntaxError as e:
            logger.error(f"Failed to parse test: {e}")
            return test_code

        setup_elements = parsed.setup_elements

        if not setup_elements:
            return test_code

        # Find which setup elements can be removed
        removable_setup = []

        for setup in setup_elements:
            # Test without this setup
            test_setup = [s for s in setup_elements if s != setup]

            # Check if test still fails without this setup
            if self._test_with_setup(parsed, test_setup, failure_predicate):
                removable_setup.append(setup)

        # Remove the identified setup elements
        if removable_setup:
            remaining_setup = [
                s for s in setup_elements if s not in removable_setup
            ]
            return self._reconstruct_with_setup(parsed, remaining_setup)

        return test_code

    # =========================================================================
    # Private Methods
    # =========================================================================

    def _parse_test(
        self,
        test_code: str,
        test_name: Optional[str] = None,
    ) -> ParsedTest:
        """Parse test code into structured components.

        Args:
            test_code: Test source code.
            test_name: Optional specific test function name.

        Returns:
            ParsedTest with all components.

        Raises:
            SyntaxError: If code cannot be parsed.
        """
        tree = ast.parse(test_code)
        source_lines = test_code.splitlines()

        # Find the test function
        function_node = None
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef):
                if test_name:
                    if node.name == test_name:
                        function_node = node
                        break
                elif node.name.startswith("test_") or node.name.startswith("test"):
                    function_node = node
                    break

        # Extract all elements
        elements: List[TestElement] = []
        imports: List[TestElement] = []
        setup_elements: List[TestElement] = []
        assertions: List[TestElement] = []

        # Process module-level statements
        for node in tree.body:
            element = self._create_element(node, source_lines)
            if element:
                elements.append(element)

                if element.element_type == "import":
                    imports.append(element)
                elif element.element_type == "setup":
                    setup_elements.append(element)
                elif element.element_type == "assertion":
                    assertions.append(element)

        # Process function body if found
        if function_node:
            for node in function_node.body:
                element = self._create_element(node, source_lines)
                if element:
                    elements.append(element)

                    if element.element_type == "setup":
                        setup_elements.append(element)
                    elif element.element_type == "assertion":
                        assertions.append(element)

        return ParsedTest(
            source=test_code,
            tree=tree,
            function_node=function_node,
            elements=elements,
            imports=imports,
            setup_elements=setup_elements,
            assertions=assertions,
        )

    def _create_element(
        self,
        node: ast.AST,
        source_lines: List[str],
    ) -> Optional[TestElement]:
        """Create a TestElement from an AST node.

        Args:
            node: AST node.
            source_lines: Source code lines.

        Returns:
            TestElement or None if not applicable.
        """
        if not hasattr(node, "lineno"):
            return None

        code = get_node_source(node, source_lines)
        if not code:
            return None

        # Determine element type
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            element_type = "import"
        elif is_assertion(node):
            element_type = "assertion"
        elif is_setup_call(node):
            element_type = "setup"
        elif isinstance(node, ast.Assign):
            element_type = "assignment"
        elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            element_type = "call"
        else:
            element_type = "other"

        # Analyze dependencies
        used_names, defined_names = self._analyzer.analyze(node)

        return TestElement(
            node=node,
            element_type=element_type,
            code=code,
            line_start=node.lineno,
            line_end=node.end_lineno or node.lineno,
            is_essential=(element_type == "import" and self.preserve_imports),
            dependencies=used_names,
            provides=defined_names,
        )

    def _get_removable_elements(
        self,
        parsed: ParsedTest,
    ) -> List[TestElement]:
        """Get elements that can potentially be removed.

        Args:
            parsed: Parsed test.

        Returns:
            List of removable elements.
        """
        removable = []

        for element in parsed.elements:
            # Skip essential elements
            if element.is_essential:
                continue

            # Keep imports if configured
            if element.element_type == "import" and self.preserve_imports:
                continue

            # Keep fixtures if configured
            if element.element_type == "setup" and self.preserve_fixtures:
                continue

            removable.append(element)

        return removable

    def _test_with_elements(
        self,
        parsed: ParsedTest,
        elements: List[TestElement],
        failure_predicate: Callable[[str], bool],
    ) -> bool:
        """Test if a subset of elements still produces failure.

        Args:
            parsed: Parsed test.
            elements: Subset of elements to test.
            failure_predicate: Function returning True if test fails.

        Returns:
            True if test still fails with this subset.
        """
        try:
            code = self._reconstruct_test(parsed, elements)
            return failure_predicate(code)
        except SyntaxError:
            return False
        except Exception as e:
            logger.debug(f"Error testing element subset: {e}")
            return False

    def _test_with_assertions(
        self,
        parsed: ParsedTest,
        assertions: List[TestElement],
        failure_predicate: Callable[[str], bool],
    ) -> bool:
        """Test if a subset of assertions still produces failure.

        Args:
            parsed: Parsed test.
            assertions: Subset of assertions to test.
            failure_predicate: Function returning True if test fails.

        Returns:
            True if test still fails with these assertions.
        """
        # Keep all non-assertion elements plus the specified assertions
        elements = [
            elem for elem in parsed.elements
            if elem.element_type != "assertion" or elem in assertions
        ]
        return self._test_with_elements(parsed, elements, failure_predicate)

    def _test_with_setup(
        self,
        parsed: ParsedTest,
        setup_elements: List[TestElement],
        failure_predicate: Callable[[str], bool],
    ) -> bool:
        """Test if a subset of setup code still produces failure.

        Args:
            parsed: Parsed test.
            setup_elements: Subset of setup elements.
            failure_predicate: Function returning True if test fails.

        Returns:
            True if test still fails with this setup.
        """
        # Keep all non-setup elements plus the specified setup
        elements = [
            elem for elem in parsed.elements
            if elem.element_type != "setup" or elem in setup_elements
        ]
        return self._test_with_elements(parsed, elements, failure_predicate)

    def _reconstruct_test(
        self,
        parsed: ParsedTest,
        keep_elements: List[TestElement],
    ) -> str:
        """Reconstruct test code keeping only specified elements.

        Args:
            parsed: Parsed test.
            keep_elements: Elements to keep.

        Returns:
            Reconstructed test code.
        """
        source_lines = parsed.source.splitlines()

        # Always keep imports
        essential_elements = [
            elem for elem in parsed.elements
            if elem.element_type == "import"
        ]

        # Add the elements we want to keep
        all_keep = set(essential_elements) | set(keep_elements)

        # Determine which lines to keep
        keep_lines: Set[int] = set()

        for elem in all_keep:
            for line_num in range(elem.line_start, elem.line_end + 1):
                keep_lines.add(line_num)

        # Handle function definition if present
        if parsed.function_node:
            # Always keep function signature
            func_line = parsed.function_node.lineno
            keep_lines.add(func_line)

            # Find decorator lines
            for decorator in parsed.function_node.decorator_list:
                if hasattr(decorator, "lineno"):
                    keep_lines.add(decorator.lineno)

        # Build result keeping blank lines for readability
        result_lines = []
        prev_kept = False

        for i, line in enumerate(source_lines, 1):
            if i in keep_lines:
                result_lines.append(line)
                prev_kept = True
            elif prev_kept and not line.strip():
                # Keep one blank line after kept content
                result_lines.append("")
                prev_kept = False

        # Clean up trailing blank lines
        while result_lines and not result_lines[-1].strip():
            result_lines.pop()

        code = "\n".join(result_lines)

        # Validate the reconstructed code parses
        try:
            ast.parse(code)
        except SyntaxError:
            # If we broke syntax, try a simpler reconstruction
            return self._simple_reconstruct(parsed, keep_elements)

        return code

    def _simple_reconstruct(
        self,
        parsed: ParsedTest,
        keep_elements: List[TestElement],
    ) -> str:
        """Simple reconstruction by joining element code.

        Fallback when line-based reconstruction breaks syntax.

        Args:
            parsed: Parsed test.
            keep_elements: Elements to keep.

        Returns:
            Reconstructed code.
        """
        parts = []

        # Add imports first
        for elem in parsed.imports:
            parts.append(elem.code)

        # Add function definition if present
        if parsed.function_node:
            # Get function signature
            decorators = ""
            for dec in parsed.function_node.decorator_list:
                dec_code = ast.unparse(dec) if hasattr(ast, "unparse") else "@decorator"
                decorators += f"@{dec_code}\n"

            args = ast.unparse(parsed.function_node.args) if hasattr(ast, "unparse") else ""
            func_sig = f"{decorators}def {parsed.function_node.name}({args}):"
            parts.append(func_sig)

            # Add body elements
            for elem in keep_elements:
                if elem not in parsed.imports:
                    # Indent body elements
                    indented = textwrap.indent(elem.code, "    ")
                    parts.append(indented)

            # Ensure function has at least pass
            body_elements = [e for e in keep_elements if e not in parsed.imports]
            if not body_elements:
                parts.append("    pass")
        else:
            # No function, just add elements
            for elem in keep_elements:
                if elem not in parsed.imports:
                    parts.append(elem.code)

        return "\n\n".join(parts)

    def _reconstruct_with_setup(
        self,
        parsed: ParsedTest,
        setup_elements: List[TestElement],
    ) -> str:
        """Reconstruct test with specific setup elements.

        Args:
            parsed: Parsed test.
            setup_elements: Setup elements to keep.

        Returns:
            Reconstructed code.
        """
        # Get all elements except removed setup
        keep_elements = [
            elem for elem in parsed.elements
            if elem.element_type != "setup" or elem in setup_elements
        ]
        return self._reconstruct_test(parsed, keep_elements)

    def _extract_setup_code(self, parsed: ParsedTest) -> str:
        """Extract setup code from parsed test.

        Args:
            parsed: Parsed test.

        Returns:
            Setup code as string.
        """
        if not parsed.setup_elements:
            return ""

        return "\n".join(elem.code for elem in parsed.setup_elements)

    def _format_assertions(
        self,
        assertions: Union[List[TestElement], List[str]],
    ) -> List[str]:
        """Format assertions as list of strings.

        Args:
            assertions: TestElements or strings.

        Returns:
            List of assertion code strings.
        """
        result = []
        for assertion in assertions:
            if isinstance(assertion, TestElement):
                result.append(assertion.code)
            else:
                result.append(str(assertion))
        return result


# =============================================================================
# Test Runner Utilities
# =============================================================================


def create_pytest_predicate(
    test_file: Path,
    test_name: str,
    timeout: int = 30,
) -> Callable[[str], bool]:
    """Create a failure predicate using pytest.

    Args:
        test_file: Path to write test code.
        test_name: Name of the test function.
        timeout: Timeout in seconds.

    Returns:
        Predicate function returning True if test fails.
    """
    def predicate(test_code: str) -> bool:
        """Run test and return True if it fails."""
        try:
            test_file.write_text(test_code)

            result = subprocess.run(
                ["pytest", str(test_file), "-x", "-v", f"-k={test_name}"],
                capture_output=True,
                text=True,
                timeout=timeout,
            )

            # Return True if test failed (non-zero exit)
            return result.returncode != 0

        except subprocess.TimeoutExpired:
            return False
        except Exception as e:
            logger.error(f"Error running test: {e}")
            return False

    return predicate


def create_exec_predicate(
    expected_exception: Optional[type] = None,
    timeout: int = 5,
) -> Callable[[str], bool]:
    """Create a failure predicate using exec().

    Args:
        expected_exception: Expected exception type, or None for any.
        timeout: Timeout in seconds (not enforced in exec).

    Returns:
        Predicate function returning True if test fails.
    """
    def predicate(test_code: str) -> bool:
        """Execute test and return True if it fails."""
        try:
            import json
            import subprocess
            import sys

            wrapper = textwrap.dedent(
                f"""
                import json,sys,traceback
                code = {test_code!r}
                try:
                    exec(code, {{}})
                except Exception as e:
                    print(json.dumps({{"ok": False, "exc": e.__class__.__name__}}))
                    sys.exit(1)
                print(json.dumps({{"ok": True}}))
                """
            ).lstrip()

            result = subprocess.run(
                [sys.executable, "-c", wrapper],
                capture_output=True,
                text=True,
                timeout=timeout,
            )
            if result.returncode == 0:
                return False
            output = (result.stdout or "").strip().splitlines()
            if not output:
                return True
            try:
                payload = json.loads(output[-1])
            except Exception:
                return True

            if payload.get("ok") is True:
                return False

            exc_name = payload.get("exc")
            if expected_exception:
                return exc_name == expected_exception.__name__
            return True
        except Exception:
            return True

    return predicate


# =============================================================================
# Convenience Functions
# =============================================================================


def minimize_test_file(
    test_file: Path,
    test_name: str,
    output_file: Optional[Path] = None,
    timeout: int = 30,
) -> MinimizedTest:
    """Minimize a test from a file.

    Args:
        test_file: Path to test file.
        test_name: Name of failing test function.
        output_file: Optional path to write minimized test.
        timeout: Timeout for test execution.

    Returns:
        MinimizedTest result.
    """
    test_code = test_file.read_text()

    with tempfile.NamedTemporaryFile(
        mode="w",
        suffix=".py",
        delete=False,
    ) as f:
        temp_path = Path(f.name)

    try:
        predicate = create_pytest_predicate(temp_path, test_name, timeout)
        minimizer = TestMinimizer(timeout=timeout)
        result = minimizer.minimize_failing_test(test_code, predicate, test_name)

        if output_file:
            output_file.write_text(result.essential_code)

        return result

    finally:
        temp_path.unlink(missing_ok=True)


def format_minimization_report(result: MinimizedTest) -> str:
    """Format minimization result as human-readable report.

    Args:
        result: MinimizedTest result.

    Returns:
        Formatted report string.
    """
    lines = [
        "",
        "Test Minimization Report",
        "========================",
        "",
        f"Original lines:   {result.original_lines}",
        f"Minimized lines:  {result.minimized_lines}",
        f"Reduction:        {result.reduction_ratio:.1%}",
        f"Iterations:       {result.iterations}",
        "",
    ]

    if result.assertions:
        lines.append("Essential assertions:")
        for assertion in result.assertions:
            lines.append(f"  - {assertion.strip()[:80]}")
        lines.append("")

    if result.removed_statements:
        lines.append(f"Removed {len(result.removed_statements)} statements:")
        for stmt in result.removed_statements[:5]:
            lines.append(f"  - {stmt.strip()[:60]}...")
        if len(result.removed_statements) > 5:
            lines.append(f"  ... and {len(result.removed_statements) - 5} more")
        lines.append("")

    lines.append("Minimized code:")
    lines.append("-" * 40)
    lines.append(result.essential_code)
    lines.append("-" * 40)

    return "\n".join(lines)
