"""Autonomous Debug Agent (Phase 10).

Fully autonomous debugging agent with tool access that can:
1. Analyze failures
2. Localize faults
3. Generate and refine fixes
4. Create pull requests

Uses an agentic loop where LLM decides what tools to use.
"""

from __future__ import annotations

import logging
import json
import os
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

from autodebug.llm_first.models import (
    ActionDecision,
    AgentAction,
    DebugResult,
    FailureContext,
    FixHypothesis,
)
from autodebug.llm_first.token_budget import TokenBudget

# H-124: Import SBFL for hierarchical fault localization
try:
    from autodebug.detection.sbfl import SBFLAnalyzer, SuspiciousLocation as SBFLLocation
    _SBFL_AVAILABLE = True
except ImportError:
    _SBFL_AVAILABLE = False

logger = logging.getLogger(__name__)

try:
    from api.metrics import inc as _metrics_inc
except Exception:  # pragma: no cover - optional in non-API contexts
    _metrics_inc = None


def _inc_metric(name: str, value: int = 1) -> None:
    if _metrics_inc is None:
        return
    try:
        _metrics_inc(name, value)
    except Exception:
        pass


# =============================================================================
# Tool Definitions
# =============================================================================

class BaseTool(ABC):
    """Base class for agent tools.

    H-132: Tools now carry a parameter schema and validate inputs
    before execution.  Subclasses should override ``param_schema``
    with a JSON-Schema-style dict describing accepted parameters.
    """

    name: str = "base_tool"
    description: str = ""

    # H-132: JSON Schema for tool parameters.  Subclasses override this.
    # Example: {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
    param_schema: Optional[Dict[str, Any]] = None

    def validate_params(self, params: Dict[str, Any]) -> List[str]:
        """Validate *params* against ``self.param_schema``.

        Returns a list of error strings (empty == valid).
        """
        if not self.param_schema:
            return []  # No schema defined -- allow anything

        errors: List[str] = []
        properties = self.param_schema.get("properties", {})
        required = self.param_schema.get("required", [])

        # Check required fields
        for field_name in required:
            if field_name not in params:
                errors.append(f"Missing required parameter: {field_name}")

        # Check types for provided parameters
        for key, value in params.items():
            if key in properties:
                expected_type = properties[key].get("type")
                if expected_type and not self._type_matches(value, expected_type):
                    errors.append(
                        f"Parameter '{key}' should be {expected_type}, "
                        f"got {type(value).__name__}"
                    )

        return errors

    @staticmethod
    def _type_matches(value: Any, json_type: str) -> bool:
        """Check if *value* matches JSON Schema *json_type*."""
        if json_type == "string":
            return isinstance(value, str)
        if json_type == "integer":
            return isinstance(value, int) and not isinstance(value, bool)
        if json_type == "number":
            return isinstance(value, (int, float)) and not isinstance(value, bool)
        if json_type == "boolean":
            return isinstance(value, bool)
        if json_type == "array":
            return isinstance(value, list)
        if json_type == "object":
            return isinstance(value, dict)
        return False  # Unknown type -- strict

    @abstractmethod
    async def execute(self, **kwargs) -> Any:
        """Execute the tool with given parameters."""
        pass


class ReadFileTool(BaseTool):
    """Read file contents."""

    name = "read_file"
    description = """
    Read the contents of a file.
    Parameters:
    - path: File path to read
    Returns: File contents as string
    """
    param_schema = {
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"],
    }

    def __init__(self, allowed_root: Optional[str] = None):
        self.allowed_root = allowed_root

    async def execute(self, path: str) -> str:
        try:
            # Sanitize path - block traversal attempts
            real_path = os.path.realpath(path)
            if self.allowed_root:
                real_root = os.path.realpath(self.allowed_root)
                if real_path != real_root and not real_path.startswith(real_root + os.sep):
                    return f"Error: Path outside allowed directory: {path}"
            with open(real_path) as f:
                contents = f.read()
            # Truncate very large files to prevent context overflow
            if len(contents) > 50_000:
                contents = contents[:50_000] + "\n... [truncated at 50KB]"
            return contents
        except Exception as e:
            return f"Error reading file: {e}"


class ReadTranscriptTool(BaseTool):
    """Read I/O transcript from a run."""

    name = "read_transcript"
    description = """
    Read the captured I/O transcript from a test run.
    Parameters:
    - run_id: ID of the run
    - dep_type: Type of dependency (HTTP, SQL, REDIS, etc.)
    Returns: Transcript records
    """
    param_schema = {
        "type": "object",
        "properties": {
            "run_id": {"type": "string"},
            "dep_type": {"type": "string"},
        },
        "required": ["run_id"],
    }

    def __init__(self, db=None):
        self.db = db

    async def execute(self, run_id: str, dep_type: str = "HTTP") -> List[Dict]:
        if not self.db:
            return []
        # Would query database for transcript
        return []


class SearchCodeTool(BaseTool):
    """Search codebase for patterns."""

    name = "search_code"
    description = """
    Search the codebase for a pattern.
    Parameters:
    - pattern: Regex pattern to search
    - file_glob: Optional file pattern (e.g., "*.py")
    Returns: List of matching locations
    """
    param_schema = {
        "type": "object",
        "properties": {
            "pattern": {"type": "string"},
            "file_glob": {"type": "string"},
        },
        "required": ["pattern"],
    }

    async def execute(self, pattern: str, file_glob: str = "*.py") -> List[Dict]:
        # Would use ripgrep or similar
        return []


class ClassifyErrorTool(BaseTool):
    """Classify error type and category."""

    name = "classify_error"
    description = """
    Classify an error into a flakiness category.
    Parameters:
    - error_type: Exception type
    - error_message: Error message
    - stack_trace: Stack trace
    Returns: Category and confidence
    """

    def __init__(self, instructor_client=None):
        self.instructor_client = instructor_client

    async def execute(
        self,
        error_type: str,
        error_message: str,
        stack_trace: str = "",
    ) -> Dict[str, Any]:
        # Would use classifier
        return {"category": "unknown", "confidence": 0.5}


class SearchSimilarFixesTool(BaseTool):
    """Search for similar historical fixes."""

    name = "search_similar_fixes"
    description = """
    Search for similar fixes from the knowledge base.
    Parameters:
    - error_type: The type of error
    - error_message: The error message
    - category: Optional category filter
    - limit: Number of results (default 5)
    Returns: List of similar fixes with success rates
    """

    def __init__(self, fix_search=None):
        self.fix_search = fix_search

    async def execute(
        self,
        error_type: str,
        error_message: str,
        category: Optional[str] = None,
        limit: int = 5,
    ) -> List[Dict]:
        if not self.fix_search:
            return []

        from autodebug.llm_first.models import FailureQuery, FlakinessCategory

        query = FailureQuery(
            error_type=error_type,
            error_message=error_message,
            category=FlakinessCategory(category) if category else None,
        )

        results = await self.fix_search.search(query, top_k=limit)
        return [
            {
                "fix_id": r.fix_id,
                "description": r.description,
                "diff": r.diff[:500],
                "success_rate": r.verification_pass_rate,
            }
            for r in results
        ]


class GenerateFixTool(BaseTool):
    """Generate a fix for the failure."""

    name = "generate_fix"
    description = """
    Generate a fix for the identified root cause.
    Parameters:
    - root_cause: Summary of the root cause
    - category: Flakiness category
    - code_context: Relevant code
    Returns: Generated fix diff and description
    """

    def __init__(self, fixer=None):
        self.fixer = fixer

    async def execute(
        self,
        root_cause: str,
        category: str,
        code_context: str,
    ) -> Dict[str, Any]:
        if not self.fixer:
            return {"error": "Fixer not available"}

        # Would use multi-hypothesis fixer
        return {"diff": "", "description": ""}


class RunTestTool(BaseTool):
    """Run the test to verify fix."""

    name = "run_test"
    description = """
    Run a specific test to verify a fix.
    Parameters:
    - test_path: Path to the test
    - test_name: Name of the test function
    Returns: Pass/fail and output
    """
    param_schema = {
        "type": "object",
        "properties": {
            "test_path": {"type": "string"},
            "test_name": {"type": "string"},
            "working_dir": {"type": "string"},
            "timeout": {"type": "integer"},
            "apply_diff": {"type": "string"},
        },
        "required": ["test_path", "test_name"],
    }

    def __init__(self, base_path: Optional[str] = None):
        self.base_path = base_path or os.getcwd()

    async def execute(
        self,
        test_path: str,
        test_name: str,
        working_dir: Optional[str] = None,
        timeout: Optional[int] = None,
        apply_diff: Optional[str] = None,
    ) -> Dict[str, Any]:
        # Delegate to production implementation used by llm_first tools package.
        from autodebug.llm_first.tools.test_tools import RunTestTool as RealRunTestTool

        tool = RealRunTestTool(base_path=self.base_path)
        return await tool.execute(
            test_path=test_path,
            test_name=test_name,
            working_dir=working_dir,
            timeout=timeout,
            apply_diff=apply_diff,
        )


class RunVerificationTool(BaseTool):
    """Run verification (N runs)."""

    name = "run_verification"
    description = """
    Run the test multiple times to verify fix is deterministic.
    Parameters:
    - test_path: Path to the test
    - test_name: Name of the test function
    - runs: Number of runs (default 10)
    Returns: Number of passes and failures
    """
    param_schema = {
        "type": "object",
        "properties": {
            "test_path": {"type": "string"},
            "test_name": {"type": "string"},
            "runs": {"type": "integer"},
            "parallel": {"type": "integer"},
            "timeout_per_run": {"type": "integer"},
            "apply_diff": {"type": "string"},
        },
        "required": ["test_path", "test_name"],
    }

    def __init__(self, base_path: Optional[str] = None):
        self.base_path = base_path or os.getcwd()

    async def execute(
        self,
        test_path: str,
        test_name: str,
        runs: int = 10,
        parallel: Optional[int] = None,
        timeout_per_run: Optional[int] = None,
        apply_diff: Optional[str] = None,
    ) -> Dict[str, Any]:
        from autodebug.llm_first.tools.test_tools import (
            RunVerificationTool as RealRunVerificationTool,
        )

        tool = RealRunVerificationTool(base_path=self.base_path)
        return await tool.execute(
            test_path=test_path,
            test_name=test_name,
            runs=runs,
            parallel=parallel,
            timeout_per_run=timeout_per_run,
            apply_diff=apply_diff,
        )


class CreatePRTool(BaseTool):
    """Create a GitHub PR with the fix."""

    name = "create_pr"
    description = """
    Create a GitHub pull request with the fix.
    Parameters:
    - title: PR title
    - body: PR description
    - diff: Fix diff to apply
    - branch: Branch name
    Returns: PR URL
    """

    def __init__(self, github_client=None):
        self.github_client = github_client

    async def execute(
        self,
        title: str,
        body: str,
        diff: str,
        branch: str,
    ) -> Dict[str, Any]:
        if not self.github_client:
            return {"error": "GitHub client not available"}

        # Would create PR
        return {"url": "https://github.com/..."}


# =============================================================================
# Tool Registry
# =============================================================================

class ToolRegistry:
    """Registry of tools available to debugging agents."""

    def __init__(
        self,
        db=None,
        fix_search=None,
        fixer=None,
        github_client=None,
        instructor_client=None,
        project_root: Optional[str] = None,
    ):
        _root = project_root or os.getcwd()
        self.tools = {
            "read_file": ReadFileTool(allowed_root=_root),
            "read_transcript": ReadTranscriptTool(db),
            "search_code": SearchCodeTool(),
            "classify_error": ClassifyErrorTool(instructor_client),
            "search_similar_fixes": SearchSimilarFixesTool(fix_search),
            "generate_fix": GenerateFixTool(fixer),
            "run_test": RunTestTool(base_path=_root),
            "run_verification": RunVerificationTool(base_path=_root),
            "create_pr": CreatePRTool(github_client),
        }

    def get_tool(self, name: str) -> Optional[BaseTool]:
        return self.tools.get(name)

    def list_tools(self) -> List[str]:
        return list(self.tools.keys())

    def format_descriptions(self) -> str:
        """Format tool descriptions for prompt."""
        parts = []
        for name, tool in self.tools.items():
            parts.append(f"- {name}: {tool.description.strip()}")
        return "\n".join(parts)


# =============================================================================
# Agent Memory
# =============================================================================

@dataclass
class AgentMemory:
    """Memory for the autonomous agent."""

    context: Optional[FailureContext] = None
    observations: List[Dict[str, Any]] = field(default_factory=list)
    hypotheses: List[str] = field(default_factory=list)
    fixes_attempted: List[FixHypothesis] = field(default_factory=list)
    # H-124: SBFL-ranked suspicious locations for hierarchical fault localization
    sbfl_suspects: List[Dict[str, Any]] = field(default_factory=list)
    max_observations: int = 200
    max_hypotheses: int = 100
    max_fixes_attempted: int = 100

    def add_context(self, context: FailureContext) -> None:
        self.context = context

    def add_observation(self, action: AgentAction, result: Any) -> None:
        self.observations.append({
            "action": action.type,
            "tool": action.tool,
            "parameters": action.parameters,
            "result": str(result)[:2000],
            "reasoning": action.reasoning,
        })
        self._trim()

    def add_hypothesis(self, hypothesis: str) -> None:
        self.hypotheses.append(hypothesis)
        self._trim()

    def add_fix_attempt(self, fix: FixHypothesis) -> None:
        self.fixes_attempted.append(fix)
        self._trim()

    def _trim(self) -> None:
        """Keep memory bounded to avoid unbounded context growth."""
        if len(self.observations) > self.max_observations:
            self.observations = self.observations[-self.max_observations :]
        if len(self.hypotheses) > self.max_hypotheses:
            self.hypotheses = self.hypotheses[-self.max_hypotheses :]
        if len(self.fixes_attempted) > self.max_fixes_attempted:
            self.fixes_attempted = self.fixes_attempted[-self.max_fixes_attempted :]

    def get_reasoning_chain(self) -> List[str]:
        return [obs["reasoning"] for obs in self.observations]

    def format_for_prompt(self) -> str:
        parts = []

        if self.context:
            parts.append(f"Error: {self.context.error_type}: {self.context.error_message}")
            parts.append(f"Test: {self.context.test_name}")

        if self.observations:
            parts.append("\nRecent observations:")
            for obs in self.observations[-10:]:
                parts.append(f"- [{obs['tool']}] {obs['reasoning'][:200]}")
                parts.append(f"  Result: {obs['result'][:2000]}")

        if self.hypotheses:
            parts.append("\nAll hypotheses (oldest to newest):")
            for i, h in enumerate(self.hypotheses):
                parts.append(f"  {i+1}. {h}")

        if self.fixes_attempted:
            parts.append(f"\nFixes attempted: {len(self.fixes_attempted)}")

        # H-124: Include SBFL-ranked suspects so the LLM prioritizes them
        if self.sbfl_suspects:
            parts.append("\nSBFL-ranked suspicious locations (highest suspiciousness first):")
            for s in self.sbfl_suspects[:10]:
                func_info = f" ({s['function']})" if s.get('function') else ""
                parts.append(f"- {s['file']}:{s['line']}{func_info} [score={s['score']:.3f}]")

        return "\n".join(parts)


# =============================================================================
# Autonomous Debug Agent
# =============================================================================

class AutonomousDebugAgent:
    """Fully autonomous debugging agent with tool access.

    Uses an agentic loop where the LLM decides what tools to use
    to investigate, diagnose, and fix test failures.
    """

    def __init__(
        self,
        instructor_client=None,
        db=None,
        fix_search=None,
        fixer=None,
        github_client=None,
        max_steps: int = 20,
        max_duration_seconds: int = 600,
        max_llm_calls_per_run: Optional[int] = None,
        llm_call_timeout_seconds: float = 60.0,
    ):
        """Initialize autonomous agent.

        Args:
            instructor_client: LLM client.
            db: Database client.
            fix_search: Fix search client.
            fixer: Multi-hypothesis fixer.
            github_client: GitHub client.
            max_steps: Maximum steps before stopping.
            max_duration_seconds: Wall-clock time limit in seconds.
        """
        self.instructor_client = instructor_client
        self.tools = ToolRegistry(
            db=db,
            fix_search=fix_search,
            fixer=fixer,
            github_client=github_client,
            instructor_client=instructor_client,
        )
        self.max_steps = min(max_steps, 50)  # Enforce upper bound
        self.max_duration_seconds = max_duration_seconds
        self.max_llm_calls_per_run = max_llm_calls_per_run or (self.max_steps * 2)
        self.llm_call_timeout_seconds = llm_call_timeout_seconds
        self.memory = AgentMemory()
        self.budget = TokenBudget()
        self._llm_calls_made = 0
        self._tool_call_cache: Dict[str, Any] = {}
        self._consecutive_low_signal = 0
        self._enforce_completion_grounding = (
            os.environ.get("HIKAFLOW_ENFORCE_COMPLETION_GROUNDING", "1").strip() != "0"
        )
        self._require_completion_evidence = (
            os.environ.get("HIKAFLOW_REQUIRE_COMPLETION_EVIDENCE", "1").strip() != "0"
        )
        self._blocked_low_signal_signatures: set[str] = set()
        self._low_signal_signature_counts: Dict[str, int] = {}
        self._low_signal_backtrack_threshold = max(
            1,
            int(os.environ.get("HIKAFLOW_LOW_SIGNAL_BACKTRACK_THRESHOLD", "2")),
        )
        # Grounding-first policy: enforce required tool ordering.
        self._tool_dependencies: Dict[str, List[str]] = {
            "search_code": ["read_transcript"],
        }

    async def debug(
        self,
        run_id: str,
        context: Optional[FailureContext] = None,
        coverage_data=None,
    ) -> DebugResult:
        """Autonomously debug a failing run.

        Args:
            run_id: ID of the failing run.
            context: Optional pre-loaded context.
            coverage_data: Optional SBFL CoverageData for fault localization.

        Returns:
            Debug result with fix or failure reason.
        """
        start_time = time.time()
        _inc_metric("autonomous.runs")
        self._llm_calls_made = 0
        self._tool_call_cache.clear()
        self._consecutive_low_signal = 0
        self._blocked_low_signal_signatures.clear()
        self._low_signal_signature_counts.clear()

        # Initialize context
        if context:
            self.memory.add_context(context)
        else:
            context = await self._load_context(run_id)
            self.memory.add_context(context)

        # H-124: Run SBFL analysis if coverage data is available
        if _SBFL_AVAILABLE and coverage_data is not None:
            try:
                sbfl = SBFLAnalyzer()
                suspects = sbfl.analyze(coverage_data, max_results=20)
                self.memory.sbfl_suspects = [
                    {
                        "file": s.file,
                        "line": s.line,
                        "function": s.function,
                        "score": s.score,
                        "rank": s.rank,
                    }
                    for s in suspects
                ]
                logger.info(
                    "SBFL analysis found %d suspicious locations", len(suspects)
                )
            except Exception as e:
                logger.warning("SBFL analysis failed: %s", e)

        # Main agent loop
        for step in range(self.max_steps):
            # Check wall-clock time limit
            elapsed = time.time() - start_time
            if elapsed > self.max_duration_seconds:
                return DebugResult(
                    success=False,
                    reason=f"Wall-clock time limit exceeded ({elapsed:.0f}s > {self.max_duration_seconds}s)",
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step,
                    tokens_used=self.budget.total_tokens,
                )

            # Check budget before making LLM call
            if not self.budget.has_budget():
                return DebugResult(
                    success=False,
                    reason="Token budget exhausted",
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step,
                    tokens_used=self.budget.total_tokens,
                )
            if self._llm_calls_made >= self.max_llm_calls_per_run:
                return DebugResult(
                    success=False,
                    reason=f"LLM call limit reached ({self._llm_calls_made}/{self.max_llm_calls_per_run})",
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step,
                    tokens_used=self.budget.total_tokens,
                )

            # LLM decides next action
            action = await self._decide_action()

            if action.type == "complete":
                evidence_error = self._validate_completion_evidence(action)
                if evidence_error:
                    _inc_metric("autonomous.policy.completion_evidence_blocked")
                    self.memory.add_observation(
                        AgentAction(
                            type="tool",
                            tool="policy_guard",
                            parameters={},
                            reasoning="completion blocked: invalid evidence object",
                        ),
                        evidence_error,
                    )
                    self._consecutive_low_signal = 0
                    continue
                if self._enforce_completion_grounding and not self._has_minimum_grounding():
                    _inc_metric("autonomous.policy.completion_grounding_blocked")
                    self.memory.add_observation(
                        AgentAction(
                            type="tool",
                            tool="policy_guard",
                            parameters={},
                            reasoning="completion blocked: insufficient grounding evidence",
                        ),
                        "Policy: completion blocked until transcript + code/classification evidence is collected.",
                    )
                    self._consecutive_low_signal = 0
                    continue
                # L-04: Verify that the conclusion is grounded in tool observations
                if self.memory.observations and action.reasoning:
                    grounding_score = self._check_conclusion_grounding(action.reasoning)
                    if grounding_score < 0.2:
                        logger.warning(
                            "Conclusion has low grounding score (%.2f) -- "
                            "reasoning may not be supported by tool observations",
                            grounding_score,
                        )

                _inc_metric("autonomous.completions")
                return DebugResult(
                    success=True,
                    fix=action.parameters.get("fix") if action.parameters else None,
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step + 1,
                    tokens_used=self.budget.total_tokens,
                )

            if action.type == "give_up":
                _inc_metric("autonomous.give_up")
                return DebugResult(
                    success=False,
                    reason=action.reasoning,
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step + 1,
                    tokens_used=self.budget.total_tokens,
                )

            # Enforce hard tool dependency policy before execution.
            policy_violation = self._validate_tool_policy(action)
            if policy_violation:
                _inc_metric("autonomous.policy.tool_dependency_blocked")
                self.memory.add_observation(action, policy_violation)
                # Keep trying with corrected guidance rather than burning
                # low-signal budget on repeated policy violations.
                self._consecutive_low_signal = 0
                continue

            # Execute tool
            result = await self._execute_tool(action.tool, action.parameters or {})
            self.memory.add_observation(action, result)
            signature = self._tool_signature(action.tool, action.parameters or {})
            if self._is_low_signal_result(result):
                count = self._low_signal_signature_counts.get(signature, 0) + 1
                self._low_signal_signature_counts[signature] = count
                if count >= self._low_signal_backtrack_threshold:
                    _inc_metric("autonomous.policy.low_signal_backtrack")
                    self._blocked_low_signal_signatures.add(signature)
                    self.memory.add_observation(
                        AgentAction(
                            type="tool",
                            tool="policy_guard",
                            parameters={},
                            reasoning="low-signal recovery: refine query or backtrack",
                        ),
                        (
                            "Policy: repeated low-signal tool output detected. "
                            "Refine query parameters or backtrack to grounding/classification tools."
                        ),
                    )
                    self._consecutive_low_signal = 0
                    continue
                self._consecutive_low_signal += 1
            else:
                self._consecutive_low_signal = 0
                self._low_signal_signature_counts.pop(signature, None)
            if self._consecutive_low_signal >= 5:
                _inc_metric("autonomous.low_signal.early_stop")
                return DebugResult(
                    success=False,
                    reason="Early stop: low-signal tool results for 5 consecutive steps",
                    reasoning_chain=self.memory.get_reasoning_chain(),
                    steps_taken=step + 1,
                    tokens_used=self.budget.total_tokens,
                )

        # Max steps reached
        return DebugResult(
            success=False,
            reason="Max steps reached without solution",
            reasoning_chain=self.memory.get_reasoning_chain(),
            steps_taken=self.max_steps,
            tokens_used=self.budget.total_tokens,
        )

    async def _load_context(self, run_id: str) -> FailureContext:
        """Load context for a run.

        Args:
            run_id: Run ID.

        Returns:
            Failure context.
        """
        # Would load from database
        return FailureContext(
            run_id=run_id,
            test_name="unknown",
            test_file="unknown",
            error_type="Unknown",
            error_message="Failed to load context",
            stack_trace="",
        )

    async def _decide_action(self) -> AgentAction:
        """LLM decides what to do next.

        Returns:
            Next action to take.
        """
        if not self.instructor_client:
            return AgentAction(
                type="give_up",
                reasoning="No LLM client available",
            )

        # H-135: Separate system instructions from user-specific state
        dependency_rules = []
        for tool_name, deps in self._tool_dependencies.items():
            if deps:
                dependency_rules.append(f"- `{tool_name}` requires: {', '.join(deps)}")
        dependency_text = "\n".join(dependency_rules) if dependency_rules else "- None"

        completion_contract = """
If action_type is "complete", parameters MUST include:
{
  "completion_evidence": {
    "evidence": [
      {
        "source_tool": "read_transcript or read_file or classify_error",
        "artifact_id": "divergence code, run ID, or file path",
        "quote_or_snippet": "exact quote or code snippet",
        "location": "file.py:line",
        "why_it_supports": "how this evidence supports the conclusion"
      }
    ],
    "divergence_ref": "artifact id or transcript pointer",
    "code_location": "file.py:line",
    "causal_chain": ["cause step 1", "cause step 2"]
  },
  "fix": "optional fix object"
}
"""

        system_instructions = f"""You are an autonomous debugging agent. Your goal is to fix failing tests.

Available tools:
{self.tools.format_descriptions()}

Tool dependency rules (hard constraints):
{dependency_text}

Options:
1. Use a tool to gather more information
2. Use a tool to generate or test a fix
3. "complete" if you have a verified fix
4. "give_up" if the problem is unsolvable

Think step by step:
1. What do I know so far?
2. What information am I missing?
3. What's my current hypothesis?
4. What action would help most?

Respond with:
- action_type: "tool", "complete", or "give_up"
- tool_name: Name of tool (if action_type is "tool")
- parameters: Tool parameters as JSON
- reasoning: Your reasoning for this decision
{completion_contract}"""

        user_query = f"""Current state:
{self.memory.format_for_prompt()}

What should you do next?"""

        prompt = (
            f"[SYSTEM INSTRUCTIONS]\n{system_instructions}\n\n"
            f"[USER QUERY]\n{user_query}"
        )

        # C-43: Retry LLM calls with exponential backoff for transient errors
        import asyncio as _asyncio

        max_retries = 3
        last_error: Exception | None = None
        for attempt in range(max_retries):
            try:
                start_ms = time.time() * 1000
                response = await _asyncio.wait_for(
                    self.instructor_client.generate(
                        prompt,
                        response_model=ActionDecision,
                    ),
                    timeout=self.llm_call_timeout_seconds,
                )
                self._llm_calls_made += 1
                duration_ms = time.time() * 1000 - start_ms

                # Record token usage from the LLM call so budget tracking is live
                usage = getattr(response, "_raw_response", None)
                input_tokens = 0
                output_tokens = 0
                model = "claude-sonnet-4-20250514"
                if usage is not None:
                    input_tokens = getattr(usage, "input_tokens", 0) or 0
                    output_tokens = getattr(usage, "output_tokens", 0) or 0
                    model = getattr(usage, "model", model) or model
                if input_tokens == 0:
                    # Estimate tokens when raw usage is unavailable
                    input_tokens = len(prompt) // 4
                    output_tokens = max(len(response.reasoning) // 4, 50) if response.reasoning else 50
                self.budget.record_usage(
                    purpose="decide_action",
                    model=model,
                    input_tokens=input_tokens,
                    output_tokens=output_tokens,
                    duration_ms=duration_ms,
                )

                # --- Structured output validation (H-116) ---
                valid_action_types = {"tool", "complete", "give_up"}
                action_type = response.action_type
                if action_type not in valid_action_types:
                    logger.warning(
                        "LLM returned invalid action_type=%r, defaulting to give_up",
                        action_type,
                    )
                    return AgentAction(
                        type="give_up",
                        reasoning=f"Invalid action_type from LLM: {action_type}",
                    )

                if action_type == "tool":
                    if not response.tool_name:
                        logger.warning("LLM returned tool action without tool_name")
                        return AgentAction(
                            type="give_up",
                            reasoning="LLM requested tool action but provided no tool_name",
                        )
                    if response.tool_name not in self.tools.list_tools():
                        logger.warning(
                            "LLM requested unregistered tool: %s (registered: %s)",
                            response.tool_name,
                            self.tools.list_tools(),
                        )
                        return AgentAction(
                            type="give_up",
                            reasoning=f"LLM requested unregistered tool: {response.tool_name}",
                        )

                return AgentAction(
                    type=action_type,
                    tool=response.tool_name,
                    parameters=response.parameters,
                    reasoning=response.reasoning,
                )

            except Exception as e:
                last_error = e
                if attempt < max_retries - 1:
                    delay = 2 ** attempt
                    logger.warning(
                        "Action decision LLM call failed (attempt %d/%d), retrying in %ds: %s",
                        attempt + 1, max_retries, delay, e,
                    )
                    await _asyncio.sleep(delay)

        logger.error(f"Action decision failed after {max_retries} attempts: {last_error}")
        return AgentAction(
            type="give_up",
            reasoning=f"Error in decision after {max_retries} retries: {last_error}",
        )

    def _check_conclusion_grounding(self, reasoning: str) -> float:
        """Check whether the agent's conclusion is grounded in tool observations.

        Uses keyword overlap between the reasoning and tool observation results
        to estimate how well the conclusion is supported by evidence.

        Args:
            reasoning: The agent's reasoning text.

        Returns:
            Grounding score between 0.0 (no overlap) and 1.0 (strong grounding).
        """
        if not self.memory.observations or not reasoning:
            return 0.5  # No observations to compare against

        # Collect unique significant words from tool observation results
        observation_words: set = set()
        for obs in self.memory.observations:
            result_text = obs.get("result", "")
            words = {
                w.lower() for w in result_text.split()
                if len(w) > 3 and w.isalpha()
            }
            observation_words.update(words)

        if not observation_words:
            return 0.5

        # Collect significant words from reasoning
        reasoning_words = {
            w.lower() for w in reasoning.split()
            if len(w) > 3 and w.isalpha()
        }

        if not reasoning_words:
            return 0.0

        # Measure overlap (Jaccard-like but only checking reasoning -> observations)
        grounded_words = reasoning_words & observation_words
        score = len(grounded_words) / len(reasoning_words) if reasoning_words else 0.0

        return min(1.0, score)

    def _used_tools(self) -> set[str]:
        return {
            str(obs.get("tool"))
            for obs in self.memory.observations
            if obs.get("tool")
        }

    @staticmethod
    def _tool_signature(tool: Optional[str], parameters: Dict[str, Any]) -> str:
        if not tool:
            return "no_tool:{}"
        try:
            return f"{tool}:{json.dumps(parameters, sort_keys=True, default=str)}"
        except Exception:
            return f"{tool}:{repr(parameters)}"

    def _validate_tool_policy(self, action: AgentAction) -> Optional[str]:
        """Validate hard tool ordering constraints.

        Returns:
            A policy feedback message when blocked, else None.
        """
        if action.type != "tool" or not action.tool:
            return None

        signature = self._tool_signature(action.tool, action.parameters or {})
        if signature in self._blocked_low_signal_signatures:
            return (
                "Policy: this tool call path produced repeated low-signal output. "
                "Refine parameters or backtrack to a different grounding step."
            )

        required_before = self._tool_dependencies.get(action.tool, [])
        if not required_before:
            return None

        used = self._used_tools()
        missing = [dep for dep in required_before if dep not in used]
        if not missing:
            return None

        run_id = self.memory.context.run_id if self.memory.context else "run_id"
        return (
            f"Policy: tool '{action.tool}' requires prior tool(s): "
            f"{', '.join(missing)}. Call "
            f"`read_transcript(run_id='{run_id}')` before broad code search."
        )

    def _validate_completion_evidence(self, action: AgentAction) -> Optional[str]:
        """Require structured completion evidence before finishing.

        Accepts two formats:
        1. Legacy: {divergence_ref, code_location, causal_chain}
        2. Structured: {evidence: [{source_tool, artifact_id, quote, location, why_it_supports}], ...}
        """
        if not self._require_completion_evidence:
            return None
        params = action.parameters or {}
        evidence = params.get("completion_evidence")
        if not isinstance(evidence, dict):
            return (
                "Policy: completion requires `completion_evidence` object with "
                "`divergence_ref`, `code_location`, and `causal_chain` — or "
                "`evidence` list with structured items."
            )

        # Accept structured evidence list format
        evidence_list = evidence.get("evidence")
        if isinstance(evidence_list, list) and len(evidence_list) >= 1:
            for item in evidence_list:
                if not isinstance(item, dict):
                    continue
                # Each item should have at least source_tool and location
                if item.get("source_tool") and item.get("location"):
                    return None  # Valid structured evidence
            return "Policy: structured evidence items must include `source_tool` and `location`."

        # Legacy format validation
        divergence_ref = evidence.get("divergence_ref")
        code_location = evidence.get("code_location")
        causal_chain = evidence.get("causal_chain")
        if not isinstance(divergence_ref, str) or not divergence_ref.strip():
            return "Policy: completion evidence must include non-empty `divergence_ref`."
        if not isinstance(code_location, str) or not code_location.strip():
            return "Policy: completion evidence must include non-empty `code_location`."
        if not isinstance(causal_chain, list) or len(causal_chain) < 2:
            return "Policy: completion evidence must include `causal_chain` with at least 2 steps."
        if any(not isinstance(step, str) or not step.strip() for step in causal_chain):
            return "Policy: completion evidence `causal_chain` must contain non-empty strings."
        return None

    def _has_minimum_grounding(self) -> bool:
        """Require baseline evidence before allowing completion.

        Preferred evidence is `read_transcript`; however for legacy workflows
        and tests that do not use transcript tooling, allow completion after at
        least two non-policy tool observations.
        """
        used = self._used_tools() - {"policy_guard"}
        if "read_transcript" in used:
            return True
        return len(used) >= 2

    async def _execute_tool(
        self,
        tool_name: Optional[str],
        parameters: Dict[str, Any],
    ) -> Any:
        """Execute a tool with given parameters.

        Args:
            tool_name: Name of the tool.
            parameters: Tool parameters.

        Returns:
            Tool result.
        """
        if not tool_name:
            return "No tool specified"
        try:
            signature = f"{tool_name}:{json.dumps(parameters, sort_keys=True, default=str)}"
        except Exception:
            signature = f"{tool_name}:{repr(parameters)}"
        if signature in self._tool_call_cache:
            return self._tool_call_cache[signature]

        tool = self.tools.get_tool(tool_name)
        if not tool:
            return f"Unknown tool: {tool_name}"

        if tool_name == "search_code" and "read_transcript" not in self._used_tools():
            run_id = self.memory.context.run_id if self.memory.context else "run_id"
            return (
                "Policy: tool 'search_code' requires prior tool(s): read_transcript. "
                f"Call `read_transcript(run_id='{run_id}')` before broad code search."
            )

        # H-132: Validate parameters against schema before execution
        schema_errors = tool.validate_params(parameters)
        if schema_errors:
            logger.warning(
                "Schema validation failed for %s: %s", tool_name, schema_errors
            )
            return f"Invalid parameters for {tool_name}: {'; '.join(schema_errors)}"

        # Validate parameters: only pass kwargs the tool's execute method accepts
        import inspect
        sig = inspect.signature(tool.execute)
        valid_params = set(sig.parameters.keys()) - {"self"}
        filtered = {k: v for k, v in parameters.items() if k in valid_params}

        try:
            result = await tool.execute(**filtered)
            # Keep cache bounded.
            if len(self._tool_call_cache) >= 200:
                oldest = next(iter(self._tool_call_cache))
                self._tool_call_cache.pop(oldest, None)
            self._tool_call_cache[signature] = result
            return result
        except TypeError as e:
            logger.error(f"Tool parameter error: {e}")
            return f"Invalid parameters for {tool_name}: {e}"
        except Exception as e:
            logger.error(f"Tool execution failed: {e}")
            return f"Error: {e}"

    def _is_low_signal_result(self, result: Any) -> bool:
        """Heuristic for low-information tool outputs."""
        if result is None:
            return True
        if isinstance(result, str):
            text = result.strip().lower()
            if not text:
                return True
            return text.startswith("error") or text in {"[]", "{}"}
        if isinstance(result, (list, dict)):
            return len(result) == 0
        return False


# =============================================================================
# Simplified Entry Point
# =============================================================================

async def autonomous_debug(
    run_id: str,
    instructor_client=None,
    db=None,
) -> DebugResult:
    """Entry point for autonomous debugging.

    Args:
        run_id: ID of the failing run.
        instructor_client: LLM client.
        db: Database client.

    Returns:
        Debug result.
    """
    agent = AutonomousDebugAgent(
        instructor_client=instructor_client,
        db=db,
    )

    return await agent.debug(run_id)
