"""Specification Extractor for LLM-First Debugging.

Extracts formal specifications from test assertions to enable verification
that generated fixes satisfy the original test requirements.
"""

from __future__ import annotations

import ast
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple

# =============================================================================
# Enums
# =============================================================================


class SpecType(str, Enum):
    """Types of specifications extracted from assertions."""

    EQUALITY = "equality"  # x == y
    ORDERING = "ordering"  # x < y, x <= y, x > y, x >= y
    MEMBERSHIP = "membership"  # x in y, x not in y
    TYPE_CHECK = "type_check"  # isinstance(x, T)
    RANGE = "range"  # a <= x <= b
    NOT_NONE = "not_none"  # x is not None
    EXCEPTION = "exception"  # pytest.raises(E)
    CUSTOM = "custom"  # Complex or unsupported assertions


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


@dataclass
class Specification:
    """A formal specification extracted from a test assertion."""

    type: SpecType
    lhs: str  # Left-hand side expression
    rhs: str  # Right-hand side expression
    operator: str  # The comparison operator
    constraint: str  # Human-readable constraint description
    source_line: int  # Line number in source
    negated: bool = False  # Whether the assertion is negated

    # Additional metadata
    original_code: str = ""
    context: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Ensure context is initialized."""
        if self.context is None:
            self.context = {}


# =============================================================================
# AST Visitors
# =============================================================================


class AssertionVisitor(ast.NodeVisitor):
    """AST visitor that extracts assertions from test code."""

    def __init__(self) -> None:
        self.assertions: List[ast.Assert] = []
        self.pytest_raises: List[Tuple[int, str]] = []
        self.unittest_assertions: List[Tuple[int, str, List[ast.expr]]] = []

    def visit_Assert(self, node: ast.Assert) -> None:
        """Collect assert statements."""
        self.assertions.append(node)
        self.generic_visit(node)

    def visit_With(self, node: ast.With) -> None:
        """Check for pytest.raises context managers."""
        for item in node.items:
            if self._is_pytest_raises(item.context_expr):
                exception_type = self._extract_exception_type(item.context_expr)
                if exception_type:
                    self.pytest_raises.append((node.lineno, exception_type))
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        """Check for unittest-style assertions."""
        if isinstance(node.func, ast.Attribute):
            method_name = node.func.attr
            if method_name.startswith("assert") or method_name.startswith("fail"):
                self.unittest_assertions.append(
                    (node.lineno, method_name, node.args)
                )
        self.generic_visit(node)

    def _is_pytest_raises(self, node: ast.expr) -> bool:
        """Check if a node is a pytest.raises call."""
        if isinstance(node, ast.Call):
            func = node.func
            # pytest.raises(...)
            if isinstance(func, ast.Attribute):
                if func.attr == "raises":
                    if isinstance(func.value, ast.Name) and func.value.id == "pytest":
                        return True
            # from pytest import raises; raises(...)
            if isinstance(func, ast.Name) and func.id == "raises":
                return True
        return False

    def _extract_exception_type(self, node: ast.Call) -> Optional[str]:
        """Extract the exception type from pytest.raises call."""
        if node.args:
            first_arg = node.args[0]
            return ast.unparse(first_arg) if hasattr(ast, "unparse") else self._node_to_string(first_arg)
        return None

    def _node_to_string(self, node: ast.expr) -> str:
        """Convert an AST node to a string representation."""
        if isinstance(node, ast.Name):
            return node.id
        if isinstance(node, ast.Attribute):
            return f"{self._node_to_string(node.value)}.{node.attr}"
        return "<unknown>"


# =============================================================================
# Main Extractor Class
# =============================================================================


class SpecificationExtractor:
    """Extracts formal specifications from test assertions.

    This class parses pytest and unittest-style test code and extracts
    formal specifications that can be used to verify that generated fixes
    satisfy the original test requirements.

    Optionally accepts an ``instructor_client`` for LLM-augmented
    verification of fix-spec satisfaction on complex / CUSTOM specs
    that cannot be resolved by static analysis alone.

    Usage:
        extractor = SpecificationExtractor()
        specs = extractor.extract_from_test(test_code)
        for spec in specs:
            print(extractor.to_constraint_string(spec))
    """

    # Mapping of comparison operators to their inverse
    INVERSE_OPS: Dict[str, str] = {
        "==": "!=",
        "!=": "==",
        "<": ">=",
        "<=": ">",
        ">": "<=",
        ">=": "<",
        "in": "not in",
        "not in": "in",
        "is": "is not",
        "is not": "is",
    }

    # Mapping of unittest methods to (operator, swap_operands)
    UNITTEST_METHODS: Dict[str, Tuple[str, bool]] = {
        "assertEqual": ("==", False),
        "assertNotEqual": ("!=", False),
        "assertTrue": ("==", False),  # Special handling needed
        "assertFalse": ("==", False),  # Special handling needed
        "assertIs": ("is", False),
        "assertIsNot": ("is not", False),
        "assertIsNone": ("is", False),  # Special handling
        "assertIsNotNone": ("is not", False),  # Special handling
        "assertIn": ("in", False),
        "assertNotIn": ("not in", False),
        "assertIsInstance": ("isinstance", False),
        "assertNotIsInstance": ("not isinstance", False),
        "assertGreater": (">", False),
        "assertGreaterEqual": (">=", False),
        "assertLess": ("<", False),
        "assertLessEqual": ("<=", False),
        "assertAlmostEqual": ("~=", False),
        "assertNotAlmostEqual": ("!~=", False),
        "assertRaises": ("raises", False),
    }

    def __init__(self, instructor_client=None) -> None:
        """Initialize the specification extractor.

        Args:
            instructor_client: Optional LLM client for semantic verification.
                If provided, ``verify_fix_satisfies`` will use the LLM to
                check whether a fix satisfies CUSTOM specs that static
                analysis cannot resolve.
        """
        self._cached_trees: Dict[str, ast.Module] = {}
        self._instructor_client = instructor_client

    def extract_from_test(self, test_code: str) -> List[Specification]:
        """Extract all specifications from test code.

        Args:
            test_code: The test source code to analyze.

        Returns:
            List of Specification objects extracted from the test.
        """
        specifications: List[Specification] = []

        try:
            tree = ast.parse(test_code)
        except SyntaxError:
            return specifications

        visitor = AssertionVisitor()
        visitor.visit(tree)

        # Process pytest-style assert statements
        for assertion in visitor.assertions:
            spec = self.extract_from_assertion(assertion)
            if spec:
                spec.original_code = self._get_assertion_source(test_code, assertion)
                specifications.append(spec)

        # Process pytest.raises
        for lineno, exception_type in visitor.pytest_raises:
            spec = Specification(
                type=SpecType.EXCEPTION,
                lhs="code block",
                rhs=exception_type,
                operator="raises",
                constraint=f"Code must raise {exception_type}",
                source_line=lineno,
                negated=False,
            )
            specifications.append(spec)

        # Process unittest-style assertions
        for lineno, method_name, args in visitor.unittest_assertions:
            spec = self._extract_from_unittest(lineno, method_name, args)
            if spec:
                specifications.append(spec)

        return specifications

    def extract_from_assertion(self, assertion: ast.Assert) -> Optional[Specification]:
        """Extract a specification from a single assertion.

        Args:
            assertion: An AST Assert node.

        Returns:
            A Specification object, or None if the assertion cannot be parsed.
        """
        test_node = assertion.test
        lineno = assertion.lineno

        # Handle negation (assert not ...)
        negated = False
        if isinstance(test_node, ast.UnaryOp) and isinstance(test_node.op, ast.Not):
            negated = True
            test_node = test_node.operand

        # Handle comparison expressions
        if isinstance(test_node, ast.Compare):
            return self._extract_from_compare(test_node, lineno, negated)

        # Handle boolean calls like isinstance, callable, etc.
        if isinstance(test_node, ast.Call):
            return self._extract_from_call(test_node, lineno, negated)

        # Handle simple name (assert x -> x is truthy)
        if isinstance(test_node, ast.Name):
            return Specification(
                type=SpecType.CUSTOM,
                lhs=test_node.id,
                rhs="True",
                operator="is truthy" if not negated else "is falsy",
                constraint=f"{test_node.id} must be {'truthy' if not negated else 'falsy'}",
                source_line=lineno,
                negated=negated,
            )

        # Handle attribute access (assert obj.attr)
        if isinstance(test_node, ast.Attribute):
            expr = self._unparse(test_node)
            return Specification(
                type=SpecType.CUSTOM,
                lhs=expr,
                rhs="True",
                operator="is truthy" if not negated else "is falsy",
                constraint=f"{expr} must be {'truthy' if not negated else 'falsy'}",
                source_line=lineno,
                negated=negated,
            )

        # Handle boolean operations (and, or)
        if isinstance(test_node, ast.BoolOp):
            return self._extract_from_boolop(test_node, lineno, negated)

        # Fallback to custom
        expr = self._unparse(test_node)
        return Specification(
            type=SpecType.CUSTOM,
            lhs=expr,
            rhs="True",
            operator="evaluates to",
            constraint=f"Expression must evaluate to {'True' if not negated else 'False'}",
            source_line=lineno,
            negated=negated,
        )

    def to_constraint_string(self, spec: Specification) -> str:
        """Convert a specification to a human-readable constraint string.

        Args:
            spec: The specification to convert.

        Returns:
            A human-readable constraint string.
        """
        if spec.type == SpecType.EQUALITY:
            if spec.negated:
                return f"{spec.lhs} != {spec.rhs}"
            return f"{spec.lhs} == {spec.rhs}"

        elif spec.type == SpecType.ORDERING:
            op = self.INVERSE_OPS.get(spec.operator, spec.operator) if spec.negated else spec.operator
            return f"{spec.lhs} {op} {spec.rhs}"

        elif spec.type == SpecType.MEMBERSHIP:
            if spec.negated:
                op = "not in" if spec.operator == "in" else "in"
            else:
                op = spec.operator
            return f"{spec.lhs} {op} {spec.rhs}"

        elif spec.type == SpecType.TYPE_CHECK:
            if spec.negated:
                return f"not isinstance({spec.lhs}, {spec.rhs})"
            return f"isinstance({spec.lhs}, {spec.rhs})"

        elif spec.type == SpecType.RANGE:
            return spec.constraint

        elif spec.type == SpecType.NOT_NONE:
            if spec.negated:
                return f"{spec.lhs} is None"
            return f"{spec.lhs} is not None"

        elif spec.type == SpecType.EXCEPTION:
            if spec.negated:
                return f"Code must not raise {spec.rhs}"
            return f"Code must raise {spec.rhs}"

        else:  # CUSTOM
            return spec.constraint

    def verify_fix_satisfies(
        self,
        fix_code: str,
        specs: List[Specification],
    ) -> bool:
        """Verify that a fix satisfies all given specifications.

        This performs static analysis to check if the fix code is likely
        to satisfy the specifications. It does not execute the code.

        Args:
            fix_code: The proposed fix code.
            specs: List of specifications to verify against.

        Returns:
            True if the fix appears to satisfy all specifications.
        """
        if not specs:
            return True

        try:
            fix_tree = ast.parse(fix_code)
        except SyntaxError:
            return False

        # Collect all assignments in the fix
        assignments = self._collect_assignments(fix_tree)

        # Collect all return statements
        returns = self._collect_returns(fix_tree)

        # Check each specification
        for spec in specs:
            if not self._check_spec_satisfaction(spec, fix_tree, assignments, returns):
                return False

        return True

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

    def _extract_from_compare(
        self,
        node: ast.Compare,
        lineno: int,
        negated: bool,
    ) -> Optional[Specification]:
        """Extract specification from a comparison expression."""
        # Handle chained comparisons (e.g., 0 < x < 10)
        if len(node.ops) > 1:
            return self._extract_from_chained_compare(node, lineno, negated)

        left = self._unparse(node.left)
        right = self._unparse(node.comparators[0])
        op = node.ops[0]

        # Determine the operator string and spec type
        if isinstance(op, ast.Eq):
            return Specification(
                type=SpecType.EQUALITY,
                lhs=left,
                rhs=right,
                operator="==",
                constraint=f"{left} must equal {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, ast.NotEq):
            return Specification(
                type=SpecType.EQUALITY,
                lhs=left,
                rhs=right,
                operator="!=",
                constraint=f"{left} must not equal {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, (ast.Lt, ast.LtE, ast.Gt, ast.GtE)):
            op_str = self._op_to_str(op)
            return Specification(
                type=SpecType.ORDERING,
                lhs=left,
                rhs=right,
                operator=op_str,
                constraint=f"{left} must be {op_str} {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, ast.In):
            return Specification(
                type=SpecType.MEMBERSHIP,
                lhs=left,
                rhs=right,
                operator="in",
                constraint=f"{left} must be in {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, ast.NotIn):
            return Specification(
                type=SpecType.MEMBERSHIP,
                lhs=left,
                rhs=right,
                operator="not in",
                constraint=f"{left} must not be in {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, ast.Is):
            # Check for "is None"
            if right == "None":
                return Specification(
                    type=SpecType.NOT_NONE,
                    lhs=left,
                    rhs="None",
                    operator="is",
                    constraint=f"{left} must be None",
                    source_line=lineno,
                    negated=negated,
                )
            return Specification(
                type=SpecType.EQUALITY,
                lhs=left,
                rhs=right,
                operator="is",
                constraint=f"{left} must be {right}",
                source_line=lineno,
                negated=negated,
            )
        elif isinstance(op, ast.IsNot):
            if right == "None":
                return Specification(
                    type=SpecType.NOT_NONE,
                    lhs=left,
                    rhs="None",
                    operator="is not",
                    constraint=f"{left} must not be None",
                    source_line=lineno,
                    negated=negated,
                )
            return Specification(
                type=SpecType.EQUALITY,
                lhs=left,
                rhs=right,
                operator="is not",
                constraint=f"{left} must not be {right}",
                source_line=lineno,
                negated=negated,
            )

        # Fallback
        return Specification(
            type=SpecType.CUSTOM,
            lhs=left,
            rhs=right,
            operator=self._op_to_str(op),
            constraint=f"{left} {self._op_to_str(op)} {right}",
            source_line=lineno,
            negated=negated,
        )

    def _extract_from_chained_compare(
        self,
        node: ast.Compare,
        lineno: int,
        negated: bool,
    ) -> Specification:
        """Extract specification from a chained comparison (e.g., 0 < x < 10)."""
        parts = [self._unparse(node.left)]
        ops = []

        for op, comparator in zip(node.ops, node.comparators):
            ops.append(self._op_to_str(op))
            parts.append(self._unparse(comparator))

        # Build the constraint string
        constraint_parts = []
        for i, op in enumerate(ops):
            constraint_parts.append(f"{parts[i]} {op} {parts[i + 1]}")

        constraint = " and ".join(constraint_parts)

        return Specification(
            type=SpecType.RANGE,
            lhs=parts[1] if len(parts) == 3 else self._unparse(node),
            rhs=f"[{parts[0]}, {parts[-1]}]" if len(parts) == 3 else "",
            operator="range",
            constraint=constraint,
            source_line=lineno,
            negated=negated,
        )

    def _extract_from_call(
        self,
        node: ast.Call,
        lineno: int,
        negated: bool,
    ) -> Optional[Specification]:
        """Extract specification from a function call assertion."""
        func_name = self._get_func_name(node)

        # isinstance(x, T)
        if func_name == "isinstance" and len(node.args) >= 2:
            obj = self._unparse(node.args[0])
            type_spec = self._unparse(node.args[1])
            return Specification(
                type=SpecType.TYPE_CHECK,
                lhs=obj,
                rhs=type_spec,
                operator="isinstance",
                constraint=f"{obj} must be an instance of {type_spec}",
                source_line=lineno,
                negated=negated,
            )

        # callable(x)
        if func_name == "callable" and len(node.args) >= 1:
            obj = self._unparse(node.args[0])
            return Specification(
                type=SpecType.CUSTOM,
                lhs=obj,
                rhs="callable",
                operator="is",
                constraint=f"{obj} must be callable",
                source_line=lineno,
                negated=negated,
            )

        # hasattr(x, 'attr')
        if func_name == "hasattr" and len(node.args) >= 2:
            obj = self._unparse(node.args[0])
            attr = self._unparse(node.args[1])
            return Specification(
                type=SpecType.CUSTOM,
                lhs=obj,
                rhs=attr,
                operator="has attribute",
                constraint=f"{obj} must have attribute {attr}",
                source_line=lineno,
                negated=negated,
            )

        # len(x) comparisons are handled at the Compare level
        # Generic function call
        call_str = self._unparse(node)
        return Specification(
            type=SpecType.CUSTOM,
            lhs=call_str,
            rhs="True",
            operator="evaluates to",
            constraint=f"{call_str} must be {'truthy' if not negated else 'falsy'}",
            source_line=lineno,
            negated=negated,
        )

    def _extract_from_boolop(
        self,
        node: ast.BoolOp,
        lineno: int,
        negated: bool,
    ) -> Specification:
        """Extract specification from a boolean operation (and/or)."""
        op_name = "and" if isinstance(node.op, ast.And) else "or"
        values = [self._unparse(v) for v in node.values]
        expr = f" {op_name} ".join(values)

        return Specification(
            type=SpecType.CUSTOM,
            lhs=expr,
            rhs="True",
            operator=op_name,
            constraint=f"({expr}) must be {'True' if not negated else 'False'}",
            source_line=lineno,
            negated=negated,
        )

    def _extract_from_unittest(
        self,
        lineno: int,
        method_name: str,
        args: List[ast.expr],
    ) -> Optional[Specification]:
        """Extract specification from unittest-style assertion."""
        if method_name not in self.UNITTEST_METHODS:
            return None

        operator, swap = self.UNITTEST_METHODS[method_name]

        # Handle special cases
        if method_name == "assertTrue":
            if args:
                expr = self._unparse(args[0])
                return Specification(
                    type=SpecType.CUSTOM,
                    lhs=expr,
                    rhs="True",
                    operator="==",
                    constraint=f"{expr} must be truthy",
                    source_line=lineno,
                    negated=False,
                )

        elif method_name == "assertFalse":
            if args:
                expr = self._unparse(args[0])
                return Specification(
                    type=SpecType.CUSTOM,
                    lhs=expr,
                    rhs="False",
                    operator="==",
                    constraint=f"{expr} must be falsy",
                    source_line=lineno,
                    negated=False,
                )

        elif method_name == "assertIsNone":
            if args:
                expr = self._unparse(args[0])
                return Specification(
                    type=SpecType.NOT_NONE,
                    lhs=expr,
                    rhs="None",
                    operator="is",
                    constraint=f"{expr} must be None",
                    source_line=lineno,
                    negated=True,  # Negated because NOT_NONE type expects negated=False for "is not None"
                )

        elif method_name == "assertIsNotNone":
            if args:
                expr = self._unparse(args[0])
                return Specification(
                    type=SpecType.NOT_NONE,
                    lhs=expr,
                    rhs="None",
                    operator="is not",
                    constraint=f"{expr} must not be None",
                    source_line=lineno,
                    negated=False,
                )

        elif method_name in ("assertIsInstance", "assertNotIsInstance"):
            if len(args) >= 2:
                obj = self._unparse(args[0])
                type_spec = self._unparse(args[1])
                negated = method_name == "assertNotIsInstance"
                return Specification(
                    type=SpecType.TYPE_CHECK,
                    lhs=obj,
                    rhs=type_spec,
                    operator="isinstance" if not negated else "not isinstance",
                    constraint=f"{obj} must {'not ' if negated else ''}be an instance of {type_spec}",
                    source_line=lineno,
                    negated=negated,
                )

        elif method_name == "assertRaises":
            if args:
                exc_type = self._unparse(args[0])
                return Specification(
                    type=SpecType.EXCEPTION,
                    lhs="code block",
                    rhs=exc_type,
                    operator="raises",
                    constraint=f"Code must raise {exc_type}",
                    source_line=lineno,
                    negated=False,
                )

        # Handle standard two-argument assertions
        if len(args) >= 2:
            lhs = self._unparse(args[0])
            rhs = self._unparse(args[1])

            if swap:
                lhs, rhs = rhs, lhs

            # Determine spec type
            if operator in ("==", "!=", "is", "is not"):
                spec_type = SpecType.EQUALITY
            elif operator in ("<", "<=", ">", ">="):
                spec_type = SpecType.ORDERING
            elif operator in ("in", "not in"):
                spec_type = SpecType.MEMBERSHIP
            else:
                spec_type = SpecType.CUSTOM

            return Specification(
                type=spec_type,
                lhs=lhs,
                rhs=rhs,
                operator=operator,
                constraint=f"{lhs} {operator} {rhs}",
                source_line=lineno,
                negated=False,
            )

        return None

    def _collect_assignments(self, tree: ast.Module) -> Dict[str, ast.expr]:
        """Collect all assignments in the AST."""
        assignments: Dict[str, ast.expr] = {}

        class AssignmentCollector(ast.NodeVisitor):
            def visit_Assign(self, node: ast.Assign) -> None:
                for target in node.targets:
                    if isinstance(target, ast.Name):
                        assignments[target.id] = node.value
                self.generic_visit(node)

            def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
                if isinstance(node.target, ast.Name) and node.value:
                    assignments[node.target.id] = node.value
                self.generic_visit(node)

        AssignmentCollector().visit(tree)
        return assignments

    def _collect_returns(self, tree: ast.Module) -> List[ast.expr]:
        """Collect all return values in the AST."""
        returns: List[ast.expr] = []

        class ReturnCollector(ast.NodeVisitor):
            def visit_Return(self, node: ast.Return) -> None:
                if node.value:
                    returns.append(node.value)
                self.generic_visit(node)

        ReturnCollector().visit(tree)
        return returns

    def _check_spec_satisfaction(
        self,
        spec: Specification,
        tree: ast.Module,
        assignments: Dict[str, ast.expr],
        returns: List[ast.expr],
    ) -> bool:
        """Check if a specification is likely satisfied by the fix.

        This is a heuristic check - it looks for patterns that suggest
        the specification is addressed but cannot prove correctness.
        """
        # For exception specs, check if there's appropriate error handling
        if spec.type == SpecType.EXCEPTION:
            return self._has_exception_handling(tree, spec.rhs)

        # For NOT_NONE specs, check for None checks
        if spec.type == SpecType.NOT_NONE:
            return self._has_none_check(tree, spec.lhs)

        # For type checks, look for isinstance calls
        if spec.type == SpecType.TYPE_CHECK:
            return self._has_type_check(tree, spec.lhs, spec.rhs)

        # For other specs, check if the relevant variables are used
        lhs_used = self._variable_used(tree, spec.lhs)
        rhs_used = self._variable_used(tree, spec.rhs)

        # If neither variable is used, the fix does not touch spec-relevant code
        if not lhs_used and not rhs_used:
            return True  # Not relevant to fix

        # Static analysis cannot determine satisfaction for CUSTOM specs
        # or specs where both sides are used but we cannot prove the
        # relationship holds.  Delegate to the LLM if available.
        if self._instructor_client is not None and spec.type == SpecType.CUSTOM:
            return self._llm_check_satisfaction(fix_code, spec)

        # Conservative fallback: assume satisfied when no LLM is available
        return True

    def _llm_check_satisfaction(self, fix_code: str, spec: Specification) -> bool:
        """Use the LLM to check whether *fix_code* satisfies *spec*.

        This is a synchronous wrapper that catches all errors and falls
        back to ``True`` (conservative) on failure.
        """
        import asyncio as _asyncio
        import logging as _logging

        _log = _logging.getLogger(__name__)

        constraint = self.to_constraint_string(spec)
        prompt = (
            "Does the following Python code satisfy this test specification?\n\n"
            f"Specification: {constraint}\n"
            f"Original assertion: {spec.original_code}\n\n"
            f"Code:\n```python\n{fix_code[:3000]}\n```\n\n"
            "Answer ONLY 'yes' or 'no' with a one-sentence explanation."
        )

        try:
            # Attempt to run in the current event loop; if none exists,
            # create one.  Handles both sync and async callers.
            try:
                loop = _asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop is not None and loop.is_running():
                # Cannot await inside a sync method that is already in an
                # async context.  Fall back to conservative True.
                _log.debug("Skipping LLM spec check (event loop already running)")
                return True

            response = _asyncio.run(
                self._instructor_client.generate(prompt)
            )
            answer = str(response).strip().lower()
            return not answer.startswith("no")

        except Exception as exc:
            _log.warning("LLM spec verification failed, assuming satisfied: %s", exc)
            return True

    def _has_exception_handling(self, tree: ast.Module, exception_type: str) -> bool:
        """Check if the tree has handling for the given exception type."""
        class ExceptionChecker(ast.NodeVisitor):
            def __init__(self) -> None:
                self.found = False

            def visit_Try(self, node: ast.Try) -> None:
                for handler in node.handlers:
                    if handler.type:
                        handler_type = self._get_exc_name(handler.type)
                        if handler_type == exception_type or handler_type == "Exception":
                            self.found = True
                self.generic_visit(node)

            def visit_Raise(self, node: ast.Raise) -> None:
                if node.exc:
                    raised = self._get_exc_name(node.exc)
                    if raised == exception_type:
                        self.found = True
                self.generic_visit(node)

            def _get_exc_name(self, node: ast.expr) -> str:
                if isinstance(node, ast.Name):
                    return node.id
                if isinstance(node, ast.Call):
                    return self._get_exc_name(node.func)
                if isinstance(node, ast.Attribute):
                    return node.attr
                return ""

        checker = ExceptionChecker()
        checker.visit(tree)
        return checker.found

    def _has_none_check(self, tree: ast.Module, variable: str) -> bool:
        """Check if there's a None check for the variable."""
        class NoneChecker(ast.NodeVisitor):
            def __init__(self) -> None:
                self.found = False

            def visit_Compare(self, node: ast.Compare) -> None:
                left = self._node_to_str(node.left)
                for op, comp in zip(node.ops, node.comparators):
                    if isinstance(op, (ast.Is, ast.IsNot)):
                        right = self._node_to_str(comp)
                        if (left == variable and right == "None") or (
                            right == variable and left == "None"
                        ):
                            self.found = True
                self.generic_visit(node)

            def visit_If(self, node: ast.If) -> None:
                # Check for "if variable:" style None checks
                test_str = self._node_to_str(node.test)
                if test_str == variable:
                    self.found = True
                self.generic_visit(node)

            def _node_to_str(self, node: ast.expr) -> str:
                if isinstance(node, ast.Name):
                    return node.id
                if isinstance(node, ast.Constant):
                    return str(node.value) if node.value is not None else "None"
                if isinstance(node, ast.NameConstant):  # Python 3.7
                    return str(node.value) if node.value is not None else "None"
                return ""

        checker = NoneChecker()
        checker.visit(tree)
        return checker.found

    def _has_type_check(self, tree: ast.Module, variable: str, type_name: str) -> bool:
        """Check if there's a type check for the variable."""
        class TypeChecker(ast.NodeVisitor):
            def __init__(self) -> None:
                self.found = False

            def visit_Call(self, node: ast.Call) -> None:
                if isinstance(node.func, ast.Name) and node.func.id == "isinstance":
                    if len(node.args) >= 2:
                        checked_var = self._node_to_str(node.args[0])
                        checked_type = self._node_to_str(node.args[1])
                        if checked_var == variable and checked_type == type_name:
                            self.found = True
                self.generic_visit(node)

            def _node_to_str(self, node: ast.expr) -> str:
                if isinstance(node, ast.Name):
                    return node.id
                return ""

        checker = TypeChecker()
        checker.visit(tree)
        return checker.found

    def _variable_used(self, tree: ast.Module, variable: str) -> bool:
        """Check if a variable is used in the tree."""
        class VariableChecker(ast.NodeVisitor):
            def __init__(self) -> None:
                self.found = False

            def visit_Name(self, node: ast.Name) -> None:
                if node.id == variable:
                    self.found = True
                self.generic_visit(node)

        checker = VariableChecker()
        checker.visit(tree)
        return checker.found

    def _unparse(self, node: ast.expr) -> str:
        """Convert an AST node to source code string."""
        if hasattr(ast, "unparse"):
            return ast.unparse(node)
        return self._fallback_unparse(node)

    def _fallback_unparse(self, node: ast.expr) -> str:
        """Fallback unparse for Python < 3.9."""
        if isinstance(node, ast.Name):
            return node.id
        if isinstance(node, ast.Constant):
            return repr(node.value)
        if isinstance(node, ast.Num):  # Python 3.7
            return repr(node.n)
        if isinstance(node, ast.Str):  # Python 3.7
            return repr(node.s)
        if isinstance(node, ast.NameConstant):  # Python 3.7
            return str(node.value)
        if isinstance(node, ast.Attribute):
            return f"{self._fallback_unparse(node.value)}.{node.attr}"
        if isinstance(node, ast.Subscript):
            return f"{self._fallback_unparse(node.value)}[{self._fallback_unparse(node.slice)}]"
        if isinstance(node, ast.Call):
            func = self._fallback_unparse(node.func)
            args = ", ".join(self._fallback_unparse(a) for a in node.args)
            return f"{func}({args})"
        if isinstance(node, ast.List):
            elts = ", ".join(self._fallback_unparse(e) for e in node.elts)
            return f"[{elts}]"
        if isinstance(node, ast.Tuple):
            elts = ", ".join(self._fallback_unparse(e) for e in node.elts)
            return f"({elts})"
        if isinstance(node, ast.Dict):
            items = ", ".join(
                f"{self._fallback_unparse(k)}: {self._fallback_unparse(v)}"
                for k, v in zip(node.keys, node.values)
                if k is not None
            )
            return f"{{{items}}}"
        if isinstance(node, ast.BinOp):
            left = self._fallback_unparse(node.left)
            right = self._fallback_unparse(node.right)
            op = self._binop_to_str(node.op)
            return f"({left} {op} {right})"
        if isinstance(node, ast.UnaryOp):
            operand = self._fallback_unparse(node.operand)
            op = self._unaryop_to_str(node.op)
            return f"{op}{operand}"
        if isinstance(node, ast.Compare):
            left = self._fallback_unparse(node.left)
            parts = [left]
            for op, comp in zip(node.ops, node.comparators):
                parts.append(self._op_to_str(op))
                parts.append(self._fallback_unparse(comp))
            return " ".join(parts)
        if isinstance(node, ast.Index):  # Python 3.8
            return self._fallback_unparse(node.value)  # type: ignore

        return "<expr>"

    def _get_func_name(self, node: ast.Call) -> str:
        """Get the function name from a Call node."""
        if isinstance(node.func, ast.Name):
            return node.func.id
        if isinstance(node.func, ast.Attribute):
            return node.func.attr
        return ""

    def _op_to_str(self, op: ast.cmpop) -> str:
        """Convert a comparison operator to string."""
        mapping = {
            ast.Eq: "==",
            ast.NotEq: "!=",
            ast.Lt: "<",
            ast.LtE: "<=",
            ast.Gt: ">",
            ast.GtE: ">=",
            ast.Is: "is",
            ast.IsNot: "is not",
            ast.In: "in",
            ast.NotIn: "not in",
        }
        return mapping.get(type(op), str(op))

    def _binop_to_str(self, op: ast.operator) -> str:
        """Convert a binary operator to string."""
        mapping = {
            ast.Add: "+",
            ast.Sub: "-",
            ast.Mult: "*",
            ast.Div: "/",
            ast.FloorDiv: "//",
            ast.Mod: "%",
            ast.Pow: "**",
            ast.LShift: "<<",
            ast.RShift: ">>",
            ast.BitOr: "|",
            ast.BitXor: "^",
            ast.BitAnd: "&",
            ast.MatMult: "@",
        }
        return mapping.get(type(op), str(op))

    def _unaryop_to_str(self, op: ast.unaryop) -> str:
        """Convert a unary operator to string."""
        mapping = {
            ast.UAdd: "+",
            ast.USub: "-",
            ast.Not: "not ",
            ast.Invert: "~",
        }
        return mapping.get(type(op), str(op))

    def _get_assertion_source(self, source: str, assertion: ast.Assert) -> str:
        """Get the source code for an assertion."""
        lines = source.splitlines()
        if assertion.lineno <= len(lines):
            # Handle multi-line assertions
            start_line = assertion.lineno - 1
            end_line = assertion.end_lineno if hasattr(assertion, "end_lineno") and assertion.end_lineno else assertion.lineno
            return "\n".join(lines[start_line:end_line])
        return ""
