"""Multi-agent debate system for root cause analysis (Phase 3).

Orchestrates specialist agents in a structured debate:
1. Each specialist generates a hypothesis
2. Agents critique each other's hypotheses
3. Agents refine based on critiques
4. Judge evaluates and selects winner

Enhanced with:
- Self-consistency voting for improved reliability
- Execution grounding with divergence points and local variables
- Run diff integration for pass/fail comparison
- Agent weighting based on historical accuracy
"""

from __future__ import annotations

import asyncio
import logging
import time
from typing import Any, Dict, List, Optional

from pydantic import BaseModel

from api.diff import (
    RunDiffResult,
)
from autodebug.llm_first.execution_grounding import (
    ExecutionGrounder,
    GroundedEvidence,
)
from autodebug.llm_first.feedback import (
    AgentWeights,
)
from autodebug.llm_first.models import (
    AgentHypothesis,
    Critique,
    FailureContext,
    FlakinessCategory,
    JudgmentResponse,
    RootCauseExplanation,
)
from autodebug.llm_first.specialists import (
    SpecialistAgent,
    create_all_specialists,
)
from autodebug.llm_first.voting import (
    SelfConsistencyVoter,
)
from autodebug.prompts.sanitizer import sanitize_json, sanitize_plain

logger = logging.getLogger(__name__)


class DebateOrchestrator:
    """Orchestrates multi-agent debate for root cause analysis.

    Process:
    1. Each specialist generates an independent hypothesis
    2. Debate round: agents critique each other
    3. Refinement: agents update based on critiques
    4. Judge evaluates all refined hypotheses against evidence

    Enhanced features:
    - Self-consistency voting for more reliable hypotheses
    - Execution grounding with divergence points
    - Run diff integration for pass/fail comparison
    - Agent weighting based on historical accuracy
    """

    def __init__(
        self,
        instructor_client=None,
        max_debate_rounds: int = 1,
        enable_tree_of_thought: bool = False,
        use_voting: bool = False,
        voting_samples: int = 5,
        use_grounding: bool = True,
        agent_weights: Optional[AgentWeights] = None,
    ):
        """Initialize debate orchestrator.

        Args:
            instructor_client: LLM client for agents.
            max_debate_rounds: Number of debate rounds.
            enable_tree_of_thought: Use ToT for complex cases.
            use_voting: Enable self-consistency voting for hypotheses.
            voting_samples: Number of samples for voting (when enabled).
            use_grounding: Enable execution grounding with traces.
            agent_weights: Historical accuracy weights for agents.
        """
        self.instructor_client = instructor_client
        self.specialists = create_all_specialists(instructor_client)
        self.judge = JudgeAgent(instructor_client, agent_weights=agent_weights)
        self.max_debate_rounds = min(max(max_debate_rounds, 1), 5)  # Allow 1-5 debate rounds
        self.enable_tree_of_thought = enable_tree_of_thought

        # Self-consistency voting
        self.use_voting = use_voting
        self.voter = SelfConsistencyVoter(
            instructor_client=instructor_client,
            num_samples=voting_samples,
        ) if use_voting else None

        # Execution grounding
        self.use_grounding = use_grounding
        self.grounder = ExecutionGrounder() if use_grounding else None

        # Agent weighting
        self.agent_weights = agent_weights or AgentWeights()

    async def analyze(
        self,
        context: FailureContext,
        parallel: bool = True,
        run_diff: Optional[RunDiffResult] = None,
        passing_run_events: Optional[List[Dict[str, Any]]] = None,
        failing_run_events: Optional[List[Dict[str, Any]]] = None,
        locals_snapshot: Optional[Any] = None,
    ) -> RootCauseExplanation:
        """Run full debate analysis.

        Args:
            context: Failure context.
            parallel: Run initial hypotheses in parallel.
            run_diff: Optional diff between passing and failing runs.
            passing_run_events: Events from a passing run (for grounding).
            failing_run_events: Events from the failing run (for grounding).
            locals_snapshot: Local variables captured at failure point.

        Returns:
            Root cause explanation from debate winner.
        """
        start_time = time.time()
        total_tokens = 0

        # Build grounded evidence if enabled
        grounded_evidence: Optional[GroundedEvidence] = None
        if self.use_grounding and self.grounder:
            grounded_evidence = self.grounder.ground_with_traces(
                failure_context=context,
                passing_run_events=passing_run_events,
                failing_run_events=failing_run_events,
                locals_snapshot=locals_snapshot,
            )
            logger.debug(
                "Grounded evidence generated with confidence boost: %.2f",
                grounded_evidence.confidence_boost,
            )

        # Build grounded prompt to enhance agent context
        grounded_prompt = self._build_grounded_prompt(
            context=context,
            grounded_evidence=grounded_evidence,
            run_diff=run_diff,
        )

        # Phase 1: Each specialist generates hypothesis
        if self.use_voting and self.voter:
            # Generate multiple hypotheses per agent and vote
            hypotheses = await self._generate_voted_hypotheses(
                context=context,
                grounded_prompt=grounded_prompt,
            )
        elif parallel:
            hypotheses = await asyncio.gather(*[
                self._analyze_with_grounding(agent, context, grounded_prompt)
                for agent in self.specialists
            ])
        else:
            hypotheses = []
            for agent in self.specialists:
                h = await self._analyze_with_grounding(agent, context, grounded_prompt)
                hypotheses.append(h)

        # Apply agent weighting to initial confidence scores
        hypotheses = self._apply_agent_weights(hypotheses)

        # Filter out null/low-confidence hypotheses
        viable_hypotheses = [h for h in hypotheses if h.confidence > 0.2]

        if not viable_hypotheses:
            return self._create_null_explanation(context, hypotheses)

        # Phase 2: Debate - agents critique each other (adaptive rounds)
        all_critiques = []
        for round_num in range(self.max_debate_rounds):
            round_critiques = await self._run_debate_round(
                viable_hypotheses,
                context,
            )
            all_critiques.extend(round_critiques)

            # Check if we should continue debating
            if not self._should_continue_debate(viable_hypotheses, round_num):
                break

        # Phase 3: Refinement - agents update based on critiques
        refined = await self._refine_hypotheses(
            viable_hypotheses,
            all_critiques,
            context,
        )

        # Phase 4: Judge evaluates against evidence
        winner = await self.judge.evaluate(
            refined,
            context,
            grounded_evidence=grounded_evidence,
            run_diff=run_diff,
        )

        # Format debate transcript
        debate_transcript = self._format_debate(
            hypotheses,
            all_critiques,
            refined,
        )

        duration_ms = int((time.time() - start_time) * 1000)

        return RootCauseExplanation(
            summary=winner.summary,
            category=winner.category,
            confidence=winner.confidence,
            evidence=winner.evidence,
            divergence_location=None,
            code_snippet=winner.supporting_code,
            recommended_fixes=[winner.fix_direction] if winner.fix_direction else [],
            reasoning_chain=winner.detailed_explanation,
            debate_transcript=debate_transcript,
            dissenting_opinions=[h for h in refined if h.agent_name != winner.agent_name],
            agent_count=len(self.specialists),
            tokens_used=total_tokens,
        )

    def _should_continue_debate(
        self,
        hypotheses: List[AgentHypothesis],
        round_num: int,
    ) -> bool:
        """Decide whether to continue debating based on hypothesis convergence.

        Continue if the top-2 hypotheses are close in confidence,
        meaning debate hasn't yet resolved the disagreement.

        Args:
            hypotheses: Current hypotheses after this round.
            round_num: Current round number (0-indexed).

        Returns:
            True if another debate round is warranted.
        """
        if round_num >= 2:  # Hard max of 3 rounds (0, 1, 2)
            return False

        if len(hypotheses) < 2:
            return False

        sorted_h = sorted(hypotheses, key=lambda h: h.confidence, reverse=True)
        gap = sorted_h[0].confidence - sorted_h[1].confidence
        return gap < 0.15  # Continue if top-2 within 15%

    async def _run_debate_round(
        self,
        hypotheses: List[AgentHypothesis],
        context: FailureContext,
    ) -> List[Critique]:
        """Run one debate round where agents critique each other.

        Args:
            hypotheses: Current hypotheses.
            context: Failure context.

        Returns:
            List of critiques.
        """
        critiques = []

        for i, critic_agent in enumerate(self.specialists):
            # Each agent critiques hypotheses from other agents
            other_hypotheses = [h for j, h in enumerate(hypotheses) if j != i]

            if not other_hypotheses:
                continue

            try:
                agent_critiques = await critic_agent.critique(other_hypotheses, context)

                for target_name, critique_data in agent_critiques.items():
                    critiques.append(Critique(
                        critic_agent=critic_agent.name,
                        target_hypothesis=target_name,
                        strengths=critique_data.get("strengths", []),
                        weaknesses=critique_data.get("weaknesses", []),
                        counter_evidence=critique_data.get("counter_evidence", []),
                        agreement_score=float(critique_data.get("agreement_score", 0.5)),
                        suggested_refinement=critique_data.get("suggested_refinement"),
                    ))
            except Exception as e:
                logger.error(f"Critique from {critic_agent.name} failed: {e}")

        return critiques

    async def _refine_hypotheses(
        self,
        hypotheses: List[AgentHypothesis],
        critiques: List[Critique],
        context: FailureContext,
    ) -> List[AgentHypothesis]:
        """Refine hypotheses based on critiques.

        Args:
            hypotheses: Original hypotheses.
            critiques: Critiques from debate.
            context: Failure context.

        Returns:
            Refined hypotheses.
        """
        if not self.instructor_client:
            return hypotheses

        refined = []

        for hypothesis in hypotheses:
            # Find critiques of this hypothesis
            relevant_critiques = [
                c for c in critiques
                if c.target_hypothesis == hypothesis.agent_name
            ]

            if not relevant_critiques:
                refined.append(hypothesis)
                continue

            # Format critiques for refinement
            critique_text = "\n\n".join([
                f"From {c.critic_agent}:\n"
                f"- Strengths: {', '.join(c.strengths[:2])}\n"
                f"- Weaknesses: {', '.join(c.weaknesses[:2])}\n"
                f"- Counter-evidence: {', '.join(c.counter_evidence[:2])}\n"
                f"- Suggested refinement: {c.suggested_refinement or 'None'}"
                for c in relevant_critiques
            ])

            prompt = f"""
Refine your hypothesis based on this critique from other experts.

Your original hypothesis ({hypothesis.agent_name}):
Category: {hypothesis.category.value}
Summary: {hypothesis.summary}
Confidence: {hypothesis.confidence}
Evidence: {', '.join(hypothesis.evidence[:3])}

Critiques received:
{critique_text}

Original failure:
Error: {sanitize_plain(context.error_type)}: {sanitize_plain(context.error_message)}

Provide a refined hypothesis that:
1. Addresses valid criticisms
2. Strengthens weak points
3. Incorporates counter-evidence if valid
4. Updates confidence based on critique validity
"""

            # C-43: Retry LLM call with exponential backoff
            for attempt in range(3):
                try:
                    refined_hypothesis = await self.instructor_client.generate(
                        prompt,
                        response_model=AgentHypothesis,
                    )
                    refined_hypothesis.agent_name = hypothesis.agent_name
                    refined_hypothesis.category = hypothesis.category
                    refined.append(refined_hypothesis)
                    break
                except Exception as e:
                    if attempt < 2:
                        logger.warning(
                            "Refinement LLM call failed for %s (attempt %d/3), retrying: %s",
                            hypothesis.agent_name, attempt + 1, e,
                        )
                        await asyncio.sleep(2 ** attempt)
                    else:
                        logger.error(f"Refinement failed for {hypothesis.agent_name} after 3 attempts: {e}")
                        refined.append(hypothesis)

        return refined

    def _create_null_explanation(
        self,
        context: FailureContext,
        hypotheses: List[AgentHypothesis],
    ) -> RootCauseExplanation:
        """Create explanation when no agent has high confidence.

        Args:
            context: Failure context.
            hypotheses: Low-confidence hypotheses.

        Returns:
            Default explanation.
        """
        return RootCauseExplanation(
            summary=f"Unable to determine root cause for {context.error_type}",
            category=FlakinessCategory.UNKNOWN,
            confidence=0.0,
            evidence=[],
            recommended_fixes=[],
            reasoning_chain="All specialist agents had low confidence in their hypotheses.",
            dissenting_opinions=hypotheses,
            agent_count=len(self.specialists),
        )

    def _build_grounded_prompt(
        self,
        context: FailureContext,
        grounded_evidence: Optional[GroundedEvidence] = None,
        run_diff: Optional[RunDiffResult] = None,
    ) -> str:
        """Build a prompt enriched with grounded execution evidence.

        Args:
            context: Failure context.
            grounded_evidence: Evidence from execution grounding.
            run_diff: Diff between passing and failing runs.

        Returns:
            Enhanced prompt string with grounded information.
        """
        parts = []

        # Add divergence point information
        if grounded_evidence and grounded_evidence.divergence:
            div = grounded_evidence.divergence
            parts.append("## Divergence Point Analysis")
            parts.append(f"**First divergence at event #{div.index}**")
            parts.append(f"Type: {div.divergence_type.value}")
            parts.append(f"Description: {sanitize_plain(div.description)}")
            if div.field_diffs:
                parts.append("Field differences:")
                for fd in div.field_diffs[:5]:  # Limit to first 5
                    parts.append(f"  - {fd.path}: {sanitize_plain(fd.description)}")
            parts.append("")

        # Add relevant local variables
        if grounded_evidence and grounded_evidence.relevant_locals:
            locals_info = grounded_evidence.relevant_locals
            parts.append("## Local Variables at Failure")
            if "frame" in locals_info:
                frame = locals_info["frame"]
                parts.append(f"Function: {frame.get('function', 'unknown')}")
                parts.append(f"File: {frame.get('file', 'unknown')}:{frame.get('line', '?')}")

            if "suspicious" in locals_info and locals_info["suspicious"]:
                parts.append("**Suspicious variables:**")
                for var_name in locals_info["suspicious"][:10]:
                    var_value = locals_info.get("variables", {}).get(var_name, "?")
                    parts.append(f"  - {var_name} = {sanitize_plain(str(var_value))}")

            if "variables" in locals_info:
                other_vars = [
                    k for k in locals_info["variables"]
                    if k not in locals_info.get("suspicious", [])
                ][:5]
                if other_vars:
                    parts.append("Other relevant variables:")
                    for var_name in other_vars:
                        var_value = locals_info["variables"].get(var_name, "?")
                        parts.append(f"  - {var_name} = {sanitize_plain(str(var_value)[:100])}")
            parts.append("")

        # Add I/O transcript summary
        if grounded_evidence and grounded_evidence.io_summary:
            parts.append("## I/O Transcript Summary")
            parts.append(sanitize_plain(grounded_evidence.io_summary[:1500]))
            parts.append("")

        # Add run diff information
        if run_diff:
            parts.append("## Run Comparison (Pass vs Fail)")
            parts.append(f"Passed run: {run_diff.run_b_id} ({run_diff.total_events_b} events)")
            parts.append(f"Failed run: {run_diff.run_a_id} ({run_diff.total_events_a} events)")
            parts.append(f"Summary: {run_diff.summary}")

            if run_diff.divergence_point:
                dp = run_diff.divergence_point
                parts.append(f"\n**Divergence at event #{dp.index}:**")
                parts.append(f"Type: {dp.divergence_type.value}")
                parts.append(f"Description: {dp.description}")

                # Show events only in failing run (left) vs passing run (right)
                if dp.left_event:
                    parts.append("\nEvent in **failing** run:")
                    parts.append(f"  Type: {dp.left_event.get('type', '?')}")
                    if dp.left_event.get('type') == 'HTTP':
                        parts.append(f"  {dp.left_event.get('method', '?')} {dp.left_event.get('url', '?')}")
                        parts.append(f"  Status: {dp.left_event.get('status', '?')}")
                    elif dp.left_event.get('type') == 'SQL':
                        parts.append(f"  Query: {str(dp.left_event.get('query', '?'))[:200]}")
                        parts.append(f"  Rows: {dp.left_event.get('rowcount', '?')}")

                if dp.right_event:
                    parts.append("\nEvent in **passing** run:")
                    parts.append(f"  Type: {dp.right_event.get('type', '?')}")
                    if dp.right_event.get('type') == 'HTTP':
                        parts.append(f"  {dp.right_event.get('method', '?')} {dp.right_event.get('url', '?')}")
                        parts.append(f"  Status: {dp.right_event.get('status', '?')}")
                    elif dp.right_event.get('type') == 'SQL':
                        parts.append(f"  Query: {str(dp.right_event.get('query', '?'))[:200]}")
                        parts.append(f"  Rows: {dp.right_event.get('rowcount', '?')}")
            parts.append("")

        # Add event timeline around divergence
        if grounded_evidence and grounded_evidence.event_timeline:
            parts.append("## Event Timeline Around Divergence")
            for event in grounded_evidence.event_timeline:
                marker = ">>> " if event.get("is_divergence") else "    "
                summary = event.get("summary", event.get("type", "?"))
                parts.append(f"{marker}[{event['index']}] {event['type']}: {summary[:80]}")
            parts.append("")

        return "\n".join(parts)

    async def _analyze_with_grounding(
        self,
        agent: SpecialistAgent,
        context: FailureContext,
        grounded_prompt: str,
    ) -> AgentHypothesis:
        """Analyze with grounded evidence included in the prompt.

        Args:
            agent: Specialist agent to use.
            context: Failure context.
            grounded_prompt: Additional grounded evidence prompt.

        Returns:
            Agent hypothesis.
        """
        # Enhance context with grounded information
        enhanced_context = context.model_copy()
        if grounded_prompt and enhanced_context.surrounding_code:
            enhanced_context.surrounding_code = (
                grounded_prompt + "\n\n" + enhanced_context.surrounding_code
            )
        elif grounded_prompt:
            enhanced_context.surrounding_code = grounded_prompt

        return await agent.analyze(enhanced_context)

    async def _generate_voted_hypotheses(
        self,
        context: FailureContext,
        grounded_prompt: str,
    ) -> List[AgentHypothesis]:
        """Generate hypotheses using self-consistency voting.

        Each agent generates multiple hypotheses that are voted on
        to select the most consistent one.

        Args:
            context: Failure context.
            grounded_prompt: Additional grounded evidence prompt.

        Returns:
            List of voted hypotheses (one per agent type).
        """
        if not self.voter:
            # Fallback to regular generation
            return await asyncio.gather(*[
                self._analyze_with_grounding(agent, context, grounded_prompt)
                for agent in self.specialists
            ])

        voted_hypotheses = []

        # Enhanced context for voting
        enhanced_context = context.model_copy()
        if grounded_prompt:
            enhanced_context.surrounding_code = (
                (enhanced_context.surrounding_code or "") + "\n\n" + grounded_prompt
            )

        # H-125: Run voting for all agents in parallel using asyncio.gather
        # instead of sequential awaits (was 6 blocking LLM call sequences)
        async def _vote_for_agent(agent: SpecialistAgent) -> AgentHypothesis:
            try:
                custom_prompt = f"""
You are a {agent.name} specialist analyzing a test failure.

{grounded_prompt}

Failure Details:
- Test: {context.test_name}
- File: {context.test_file}
- Error: {sanitize_plain(context.error_type)}: {sanitize_plain(context.error_message)}

Stack Trace:
{sanitize_plain(context.stack_trace[:1500]) if context.stack_trace else 'Not available'}

Code Context:
{sanitize_plain(context.code_snippet[:1500]) if context.code_snippet else 'Not available'}

As a {agent.name} specialist, analyze this failure and provide your hypothesis.
Focus on patterns related to your specialty area.
"""
                voted = await self.voter.vote_on_hypotheses(
                    context=enhanced_context,
                    custom_prompt=custom_prompt,
                )

                hypothesis = voted.hypothesis
                hypothesis.agent_name = agent.name

                if voted.agreement_ratio > 0.6:
                    hypothesis.confidence = min(
                        0.95,
                        hypothesis.confidence + voted.agreement_ratio * 0.1,
                    )

                logger.debug(
                    "Voted hypothesis for %s: agreement=%.0f%%, confidence=%.0f%%",
                    agent.name,
                    voted.agreement_ratio * 100,
                    hypothesis.confidence * 100,
                )

                return hypothesis

            except Exception as e:
                logger.error(f"Voting failed for {agent.name}: {e}")
                return await self._analyze_with_grounding(agent, context, grounded_prompt)

        results = await asyncio.gather(
            *[_vote_for_agent(agent) for agent in self.specialists],
            return_exceptions=True,
        )

        for i, result in enumerate(results):
            if isinstance(result, Exception):
                logger.error(f"Voting gather failed for agent {i}: {result}")
                h = await self._analyze_with_grounding(
                    self.specialists[i], context, grounded_prompt
                )
                voted_hypotheses.append(h)
            else:
                voted_hypotheses.append(result)

        return voted_hypotheses

    def _apply_agent_weights(
        self,
        hypotheses: List[AgentHypothesis],
    ) -> List[AgentHypothesis]:
        """Apply historical accuracy weights to agent hypotheses.

        Higher-weighted agents get a confidence boost based on
        their historical performance.

        Args:
            hypotheses: List of agent hypotheses.

        Returns:
            Hypotheses with adjusted confidence scores.
        """
        if not self.agent_weights or not self.agent_weights.weights:
            return hypotheses

        adjusted = []
        for h in hypotheses:
            weight = self.agent_weights.get_weight(h.agent_name, default=1.0)

            # Apply weight as a multiplier on confidence
            # Weight of 1.0 = no change, 1.5 = 50% boost, 0.5 = 50% reduction
            adjusted_confidence = h.confidence * weight

            # Clamp to valid range
            adjusted_confidence = max(0.0, min(1.0, adjusted_confidence))

            # Create a modified copy
            adjusted_h = h.model_copy()
            adjusted_h.confidence = adjusted_confidence

            logger.debug(
                "Agent %s: weight=%.2f, confidence %.2f -> %.2f",
                h.agent_name,
                weight,
                h.confidence,
                adjusted_confidence,
            )

            adjusted.append(adjusted_h)

        return adjusted

    def _format_debate(
        self,
        initial_hypotheses: List[AgentHypothesis],
        critiques: List[Critique],
        refined_hypotheses: List[AgentHypothesis],
    ) -> str:
        """Format the debate transcript.

        Args:
            initial_hypotheses: Initial hypotheses.
            critiques: All critiques.
            refined_hypotheses: Refined hypotheses.

        Returns:
            Formatted transcript.
        """
        parts = ["## Multi-Agent Debate Transcript\n"]

        # Initial hypotheses
        parts.append("### Initial Hypotheses\n")
        for h in initial_hypotheses:
            parts.append(f"**{h.agent_name}** ({h.category.value}, confidence: {h.confidence:.0%})")
            parts.append(f"> {h.summary}")
            parts.append("")

        # Critiques
        if critiques:
            parts.append("### Debate Round\n")
            for c in critiques:
                parts.append(f"**{c.critic_agent}** critiques **{c.target_hypothesis}**:")
                if c.strengths:
                    parts.append(f"- Strengths: {c.strengths[0]}")
                if c.weaknesses:
                    parts.append(f"- Weaknesses: {c.weaknesses[0]}")
                parts.append(f"- Agreement: {c.agreement_score:.0%}")
                parts.append("")

        # Refined hypotheses
        parts.append("### Refined Hypotheses\n")
        for h in refined_hypotheses:
            parts.append(f"**{h.agent_name}** (refined, confidence: {h.confidence:.0%})")
            parts.append(f"> {h.summary}")
            parts.append("")

        return "\n".join(parts)


class JudgeAgent:
    """Evaluates hypotheses against evidence to select winner.

    Enhanced to consider:
    - Voting confidence from self-consistency voting
    - Grounded evidence alignment
    - Agent historical accuracy weights
    """

    def __init__(
        self,
        instructor_client=None,
        agent_weights: Optional[AgentWeights] = None,
    ):
        """Initialize judge.

        Args:
            instructor_client: LLM client for evaluation.
            agent_weights: Historical accuracy weights for agents.
        """
        self.instructor_client = instructor_client
        self.agent_weights = agent_weights or AgentWeights()

    async def evaluate(
        self,
        hypotheses: List[AgentHypothesis],
        context: FailureContext,
        grounded_evidence: Optional[GroundedEvidence] = None,
        run_diff: Optional[RunDiffResult] = None,
    ) -> AgentHypothesis:
        """Evaluate hypotheses and select winner.

        Args:
            hypotheses: Refined hypotheses to evaluate.
            context: Failure context.
            grounded_evidence: Evidence from execution grounding.
            run_diff: Diff between passing and failing runs.

        Returns:
            Winning hypothesis.
        """
        if not hypotheses:
            return AgentHypothesis(
                agent_name="Judge",
                category=FlakinessCategory.UNKNOWN,
                summary="No hypotheses to evaluate",
                detailed_explanation="",
                evidence=[],
                confidence=0.0,
            )

        if len(hypotheses) == 1:
            return hypotheses[0]

        if not self.instructor_client:
            # Return highest weighted confidence hypothesis
            return max(
                hypotheses,
                key=lambda h: self._compute_weighted_score(h, grounded_evidence),
            )

        # Format hypotheses for evaluation with agent weights
        hypotheses_text = self._format_hypotheses_with_weights(hypotheses)

        # Format evidence including grounded evidence
        evidence_text = self._format_evidence(
            context=context,
            grounded_evidence=grounded_evidence,
            run_diff=run_diff,
        )

        # Build enhanced prompt
        prompt = self._build_evaluation_prompt(
            context=context,
            hypotheses_text=hypotheses_text,
            evidence_text=evidence_text,
            grounded_evidence=grounded_evidence,
            run_diff=run_diff,
        )

        # C-43: Retry LLM call with exponential backoff
        for attempt in range(3):
            try:
                response = await self.instructor_client.generate(
                    prompt,
                    response_model=JudgmentResponse,
                )

                if 0 <= response.winner_index < len(hypotheses):
                    winner = hypotheses[response.winner_index]

                    # Compute final confidence with all factors
                    final_confidence = self._compute_final_confidence(
                        base_confidence=response.winner_confidence,
                        hypothesis=winner,
                        grounded_evidence=grounded_evidence,
                        evidence_alignment=response.evidence_alignment,
                    )

                    winner.confidence = final_confidence
                    return winner
                break  # Valid response but bad index — don't retry

            except Exception as e:
                if attempt < 2:
                    logger.warning(
                        "Judge evaluation LLM call failed (attempt %d/3), retrying: %s",
                        attempt + 1, e,
                    )
                    await asyncio.sleep(2 ** attempt)
                else:
                    logger.error(f"Judge evaluation failed after 3 attempts: {e}")

        # Fallback to highest weighted confidence
        return max(
            hypotheses,
            key=lambda h: self._compute_weighted_score(h, grounded_evidence),
        )

    def _format_hypotheses_with_weights(
        self,
        hypotheses: List[AgentHypothesis],
    ) -> str:
        """Format hypotheses including agent weight information.

        Args:
            hypotheses: List of hypotheses.

        Returns:
            Formatted string with weight annotations.
        """
        parts = []
        for i, h in enumerate(hypotheses):
            weight = self.agent_weights.get_weight(h.agent_name, default=1.0)
            accuracy = self.agent_weights.accuracy_scores.get(h.agent_name, None)
            sample_count = self.agent_weights.sample_counts.get(h.agent_name, 0)

            weight_info = f"Historical weight: {weight:.2f}"
            if accuracy is not None:
                weight_info += f" (accuracy: {accuracy:.0%} from {sample_count} samples)"

            parts.append(
                f"[{i}] {h.agent_name} ({h.category.value})\n"
                f"Summary: {h.summary}\n"
                f"Confidence: {h.confidence:.0%}\n"
                f"Evidence: {', '.join(h.evidence[:3])}\n"
                f"Fix direction: {h.fix_direction or 'Not specified'}\n"
                f"{weight_info}"
            )

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

    def _format_evidence(
        self,
        context: FailureContext,
        grounded_evidence: Optional[GroundedEvidence] = None,
        run_diff: Optional[RunDiffResult] = None,
    ) -> List[str]:
        """Format all available evidence.

        Args:
            context: Failure context.
            grounded_evidence: Grounded execution evidence.
            run_diff: Run diff result.

        Returns:
            List of evidence strings.
        """
        evidence_text = []

        # Context-based evidence
        if context.divergence_point:
            evidence_text.append(f"Divergence (context): {sanitize_plain(str(context.divergence_point))}")
        if context.locals_snapshot:
            evidence_text.append(
                f"Local variables: {sanitize_json(str(context.locals_snapshot)[:300])}"
            )
        if context.transcript_summary:
            evidence_text.append(
                f"I/O transcript: {sanitize_plain(context.transcript_summary[:300])}"
            )

        # Grounded evidence
        if grounded_evidence:
            if grounded_evidence.divergence:
                div = grounded_evidence.divergence
                evidence_text.append(
                    f"Grounded divergence at event #{div.index}: "
                    f"{div.divergence_type.value} - {div.description}"
                )

            if grounded_evidence.relevant_locals:
                suspicious = grounded_evidence.relevant_locals.get("suspicious", [])
                if suspicious:
                    evidence_text.append(
                        f"Suspicious variables: {', '.join(suspicious[:5])}"
                    )

            if grounded_evidence.io_summary:
                evidence_text.append(
                    f"I/O summary: {grounded_evidence.io_summary[:200]}"
                )

            if grounded_evidence.confidence_boost > 0:
                evidence_text.append(
                    f"Grounding confidence boost: +{grounded_evidence.confidence_boost:.0%}"
                )

        # Run diff evidence
        if run_diff and run_diff.divergence_point:
            dp = run_diff.divergence_point
            evidence_text.append(
                f"Run diff: divergence at event #{dp.index} "
                f"({dp.divergence_type.value}) - {dp.description}"
            )
            evidence_text.append(run_diff.summary)

        return evidence_text

    def _build_evaluation_prompt(
        self,
        context: FailureContext,
        hypotheses_text: str,
        evidence_text: List[str],
        grounded_evidence: Optional[GroundedEvidence] = None,
        run_diff: Optional[RunDiffResult] = None,
    ) -> str:
        """Build the complete evaluation prompt.

        Args:
            context: Failure context.
            hypotheses_text: Formatted hypotheses.
            evidence_text: List of evidence strings.
            grounded_evidence: Grounded evidence (for special instructions).
            run_diff: Run diff (for special instructions).

        Returns:
            Complete prompt string.
        """
        # Base prompt
        prompt = f"""
You are a judge evaluating competing hypotheses about this failure:

Failure: {sanitize_plain(context.error_type)}: {sanitize_plain(context.error_message)}

Evidence available:
{chr(10).join(evidence_text) if evidence_text else 'Limited evidence available'}

Hypotheses from specialist agents:
{hypotheses_text}

"""

        # Add grounding-specific instructions
        if grounded_evidence and grounded_evidence.divergence:
            prompt += """
IMPORTANT - Grounded Evidence Alignment:
The divergence point shows where execution actually differed between passing and failing runs.
A strong hypothesis should directly explain this divergence.
Score hypotheses higher if they explain the specific divergence type and location.

"""

        # Add run diff-specific instructions
        if run_diff and run_diff.divergence_point:
            prompt += """
IMPORTANT - Run Diff Analysis:
The run comparison shows concrete differences between a passing and failing run.
The winning hypothesis should explain why this specific difference caused the failure.
Events present in the failing run but not the passing run are strong evidence.

"""

        # Add agent weight instructions
        if self.agent_weights and self.agent_weights.weights:
            prompt += """
IMPORTANT - Agent Historical Accuracy:
Some agents have higher historical accuracy than others.
Use the "Historical weight" as a tie-breaker when hypotheses are otherwise equal.
Higher weight agents have been more accurate in past analyses.

"""

        prompt += """
When evaluating, weight EVIDENCE over rhetoric:
- A hypothesis with 3 pieces of concrete evidence > one with eloquent reasoning
- A hypothesis that makes testable predictions > one that's vague
- Prefer the SIMPLEST explanation that fits ALL evidence (Occam's razor)
- Explicitly state which evidence swayed your decision

For each hypothesis, score (0-1) how well it explains the evidence.
A good hypothesis:
1. Explains WHY the failure occurred (not just what happened)
2. Is consistent with ALL available evidence (no contradictions)
3. Aligns with the grounded divergence point (if available)
4. Explains the run diff findings (if available)
5. Makes testable predictions that could verify the diagnosis
6. Suggests a concrete, actionable fix with specific code location

Red flags that should LOWER a hypothesis score:
- Vague language ("might be", "could be") without concrete evidence
- Ignoring contradicting evidence
- Overly complex explanation when a simpler one fits
- No specific code location or line reference

Select the winning hypothesis that best satisfies these criteria.
Return the winner_index (0-based) and your reasoning.
Include evidence_alignment scores showing how well each hypothesis aligns with the evidence.
"""

        return prompt

    def _compute_weighted_score(
        self,
        hypothesis: AgentHypothesis,
        grounded_evidence: Optional[GroundedEvidence] = None,
    ) -> float:
        """Compute weighted score for a hypothesis.

        Combines confidence, agent weight, and grounding boost.

        Args:
            hypothesis: The hypothesis to score.
            grounded_evidence: Grounded evidence for boost.

        Returns:
            Weighted score.
        """
        base_score = hypothesis.confidence

        # Apply agent weight
        agent_weight = self.agent_weights.get_weight(
            hypothesis.agent_name, default=1.0
        )
        weighted_score = base_score * agent_weight

        # Apply grounding boost if evidence supports this category
        if grounded_evidence and grounded_evidence.confidence_boost > 0:
            # Boost is applied evenly for now; could be category-specific
            weighted_score += grounded_evidence.confidence_boost * 0.5

        return min(1.0, weighted_score)

    def _compute_final_confidence(
        self,
        base_confidence: float,
        hypothesis: AgentHypothesis,
        grounded_evidence: Optional[GroundedEvidence] = None,
        evidence_alignment: Optional[Dict[str, float]] = None,
    ) -> float:
        """Compute final confidence combining all factors.

        Args:
            base_confidence: Base confidence from LLM judgment.
            hypothesis: Winning hypothesis.
            grounded_evidence: Grounded evidence.
            evidence_alignment: Alignment scores from judgment.

        Returns:
            Final confidence score.
        """
        final = base_confidence

        # Factor in agent historical accuracy
        agent_weight = self.agent_weights.get_weight(
            hypothesis.agent_name, default=1.0
        )
        # Adjust based on weight (1.0 = no change)
        weight_adjustment = (agent_weight - 1.0) * 0.1
        final += weight_adjustment

        # Factor in grounding confidence boost
        if grounded_evidence and grounded_evidence.confidence_boost > 0:
            final += grounded_evidence.confidence_boost * 0.3

        # Factor in evidence alignment if available
        if evidence_alignment:
            avg_alignment = sum(evidence_alignment.values()) / len(evidence_alignment)
            if avg_alignment > 0.7:
                final += 0.05  # Small boost for high alignment

        # Clamp to valid range
        return max(0.0, min(0.95, final))


class TreeOfThoughtAnalyzer:
    """Explore multiple reasoning paths with backtracking.

    Uses BFS with LLM-guided node expansion for complex root cause analysis.
    """

    def __init__(
        self,
        instructor_client=None,
        max_depth: int = 4,
        branching_factor: int = 3,
    ):
        """Initialize Tree of Thought analyzer.

        Args:
            instructor_client: LLM client.
            max_depth: Maximum tree depth.
            branching_factor: Number of children per node.
        """
        self.instructor_client = instructor_client
        self.max_depth = max_depth
        self.branching_factor = branching_factor

    async def analyze(
        self,
        context: FailureContext,
    ) -> RootCauseExplanation:
        """Analyze failure using Tree of Thought.

        Args:
            context: Failure context.

        Returns:
            Root cause explanation from best reasoning path.
        """
        from autodebug.llm_first.models import ThoughtNode

        root = ThoughtNode(
            thought=f"Initial observation: {context.error_type} in {context.test_name}",
            context=context,
            depth=0,
        )

        # BFS exploration
        best_path = await self._explore_bfs(root)

        if not best_path:
            return RootCauseExplanation(
                summary="Unable to determine root cause",
                category=FlakinessCategory.UNKNOWN,
                confidence=0.0,
                evidence=[],
            )

        return self._path_to_explanation(best_path, context)

    async def _explore_bfs(
        self,
        root,
    ):
        """BFS exploration with LLM-scored nodes.

        Args:
            root: Root thought node.

        Returns:
            Best complete path.
        """

        queue = [root]
        best_path = None
        best_score = 0.0

        while queue:
            node = queue.pop(0)

            if node.depth >= self.max_depth:
                # Evaluate complete path
                score = await self._evaluate_path(node.get_path())
                if score > best_score:
                    best_score = score
                    best_path = node.get_path()
                continue

            # Expand node
            children = await self._expand_node(node)

            # Score and add children
            for child in children:
                child.score = await self._score_node(child)
                queue.append(child)

            # Sort queue by score (best first)
            queue.sort(key=lambda n: n.score, reverse=True)

            # Prune to limit width
            queue = queue[:self.branching_factor * 3]

        return best_path

    async def _expand_node(
        self,
        node,
    ):
        """Expand node with LLM-generated child thoughts.

        Args:
            node: Node to expand.

        Returns:
            List of child nodes.
        """
        from autodebug.llm_first.models import ThoughtExpansion, ThoughtNode

        if not self.instructor_client:
            return []

        path_text = " -> ".join([n.thought for n in node.get_path()])

        prompt = f"""
Current reasoning path:
{path_text}

Current thought: {node.thought}

Generate {self.branching_factor} different possible next reasoning steps.
Each should explore a different angle or hypothesis.

For each thought:
1. What are you investigating?
2. What evidence would support this?
3. What would it mean if true?
"""

        try:
            response = await self.instructor_client.generate(
                prompt,
                response_model=ThoughtExpansion,
            )

            return [
                ThoughtNode(
                    thought=t.get("thought", ""),
                    evidence_needed=t.get("evidence_needed", ""),
                    implication=t.get("implication", ""),
                    parent=node,
                    depth=node.depth + 1,
                    context=node.context,
                )
                for t in response.thoughts
            ]

        except Exception as e:
            logger.error(f"Node expansion failed: {e}")
            return []

    async def _score_node(self, node) -> float:
        """Score a thought node.

        Args:
            node: Node to score.

        Returns:
            Score (0-1).
        """
        if not self.instructor_client:
            return 0.5

        path_text = " -> ".join([n.thought for n in node.get_path()])

        prompt = f"""
Score this reasoning path for a test failure analysis:
{path_text}

Score from 0 to 1:
- 1.0: Very promising, leads to actionable root cause
- 0.7: Good direction, needs more investigation
- 0.4: Uncertain, could be relevant
- 0.1: Unlikely to lead to root cause

Return just a float score.
"""

        try:
            class ScoreResponse(BaseModel):
                score: float

            response = await self.instructor_client.generate(
                prompt,
                response_model=ScoreResponse,
            )
            return response.score

        except Exception:
            return 0.5

    async def _evaluate_path(self, path) -> float:
        """Evaluate a complete reasoning path.

        Args:
            path: Complete path from root to leaf.

        Returns:
            Path score.
        """
        if not path:
            return 0.0

        # Sum of node scores with depth weighting
        total = sum(node.score * (node.depth + 1) for node in path)
        max_possible = sum((i + 1) for i in range(len(path)))

        return total / max_possible if max_possible > 0 else 0.0

    def _path_to_explanation(
        self,
        path,
        context: FailureContext,
    ) -> RootCauseExplanation:
        """Convert reasoning path to explanation.

        Args:
            path: Best reasoning path.
            context: Failure context.

        Returns:
            Root cause explanation.
        """
        if not path:
            return RootCauseExplanation(
                summary="Unable to determine root cause",
                category=FlakinessCategory.UNKNOWN,
                confidence=0.0,
                evidence=[],
            )

        # Last node has the conclusion
        final_node = path[-1]

        # Format reasoning chain
        reasoning = "\n".join([
            f"{i+1}. {node.thought}"
            for i, node in enumerate(path)
        ])

        return RootCauseExplanation(
            summary=final_node.implication or final_node.thought,
            category=FlakinessCategory.UNKNOWN,  # Would need classification
            confidence=final_node.score,
            evidence=[node.evidence_needed for node in path if node.evidence_needed],
            reasoning_chain=reasoning,
        )
