"""Hybrid Fault Localizer combining SBFL and LLM (Phase 2).

Uses three-step approach:
1. SBFL statistical ranking (Ochiai formula)
2. LLM semantic re-ranking
3. Ensemble combination with configurable weights

Extended with execution grounding and run diff capabilities:
- Run diff analysis to find divergence points
- Execution grounding for concrete evidence
- Enhanced LLM re-ranking with execution context
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional

from autodebug.llm_first.models import (
    FailureContext,
    LocationScores,
    SuspiciousLocation,
)

# Import execution grounding and diff utilities
try:
    from .execution_grounding import ExecutionGrounder, GroundedEvidence
    GROUNDING_AVAILABLE = True
except ImportError:
    GROUNDING_AVAILABLE = False
    ExecutionGrounder = None
    GroundedEvidence = None

try:
    from api.diff import (
        DivergencePoint,
        DivergenceType,
        RunDiffResult,
        diff_runs,
    )
    DIFF_AVAILABLE = True
except ImportError:
    DIFF_AVAILABLE = False
    diff_runs = None
    DivergencePoint = None
    DivergenceType = None
    RunDiffResult = None

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


@dataclass
class TestResult:
    """Result of a single test execution."""
    test_name: str
    passed: bool
    covered_lines: Dict[str, List[int]]  # file -> lines


@dataclass
class CoverageData:
    """Coverage data for a codebase."""
    file_contents: Dict[str, str]  # file -> content
    line_coverage: Dict[str, Dict[int, int]]  # file -> line -> hit count


class SBFLAnalyzer:
    """Spectrum-Based Fault Localization using statistical formulas."""

    FORMULAS = ["ochiai", "tarantula", "dstar", "jaccard"]

    def rank_locations(
        self,
        test_results: List[TestResult],
        coverage: CoverageData,
        formula: str = "ochiai",
        top_k: int = 30,
    ) -> List[SuspiciousLocation]:
        """Rank code locations by suspiciousness.

        Args:
            test_results: List of test execution results.
            coverage: Coverage data for the codebase.
            formula: SBFL formula to use.
            top_k: Number of top locations to return.

        Returns:
            List of suspicious locations ranked by score.
        """
        # Separate passing and failing tests
        failing_tests = [t for t in test_results if not t.passed]
        passing_tests = [t for t in test_results if t.passed]

        if not failing_tests:
            return []

        # Calculate line-level statistics
        line_stats: Dict[str, Dict[int, Dict[str, int]]] = {}

        for file_path in coverage.file_contents:
            line_stats[file_path] = {}

        # Count passed/failed executions per line
        for test in test_results:
            for file_path, lines in test.covered_lines.items():
                if file_path not in line_stats:
                    line_stats[file_path] = {}

                for line in lines:
                    if line not in line_stats[file_path]:
                        line_stats[file_path][line] = {
                            "pass_count": 0,
                            "fail_count": 0,
                        }

                    if test.passed:
                        line_stats[file_path][line]["pass_count"] += 1
                    else:
                        line_stats[file_path][line]["fail_count"] += 1

        # Calculate suspiciousness scores
        total_failed = len(failing_tests)
        total_passed = len(passing_tests)

        locations = []
        for file_path, lines in line_stats.items():
            for line_num, stats in lines.items():
                # Calculate score based on formula
                score = self._calculate_score(
                    stats["fail_count"],
                    stats["pass_count"],
                    total_failed,
                    total_passed,
                    formula,
                )

                # Get code snippet
                code_snippet = self._get_code_snippet(
                    coverage.file_contents.get(file_path, ""),
                    line_num,
                )

                locations.append(SuspiciousLocation(
                    id=f"{file_path}:{line_num}",
                    file=file_path,
                    line=line_num,
                    code_snippet=code_snippet,
                    sbfl_score=score,
                    sbfl_formula=formula,
                    pass_count=stats["pass_count"],
                    fail_count=stats["fail_count"],
                ))

        # Sort by score and return top-k
        locations.sort(key=lambda x: x.sbfl_score, reverse=True)
        return locations[:top_k]

    def _calculate_score(
        self,
        failed_covered: int,
        passed_covered: int,
        total_failed: int,
        total_passed: int,
        formula: str,
    ) -> float:
        """Calculate suspiciousness score using SBFL formula.

        Args:
            failed_covered: Number of failed tests that covered this line.
            passed_covered: Number of passed tests that covered this line.
            total_failed: Total number of failed tests.
            total_passed: Total number of passed tests.
            formula: Formula to use.

        Returns:
            Suspiciousness score (0-1).
        """
        # Prevent division by zero
        failed_covered = max(failed_covered, 0)
        passed_covered = max(passed_covered, 0)

        if formula == "ochiai":
            # Ochiai: ef / sqrt((ef + nf) * (ef + ep))
            denom = math.sqrt((total_failed) * (failed_covered + passed_covered))
            if denom == 0:
                return 0.0
            return failed_covered / denom

        elif formula == "tarantula":
            # Tarantula: (ef/nf) / ((ef/nf) + (ep/np))
            if total_failed == 0 or total_passed == 0:
                return 0.0
            fail_ratio = failed_covered / total_failed
            pass_ratio = passed_covered / total_passed if total_passed > 0 else 0
            denom = fail_ratio + pass_ratio
            if denom == 0:
                return 0.0
            return fail_ratio / denom

        elif formula == "dstar":
            # D*: ef^2 / (nf + ep)
            denom = (total_failed - failed_covered) + passed_covered
            if denom == 0:
                return float(failed_covered ** 2) if failed_covered > 0 else 0.0
            return (failed_covered ** 2) / denom

        elif formula == "jaccard":
            # Jaccard: ef / (nf + ep + ef)
            denom = total_failed + passed_covered
            if denom == 0:
                return 0.0
            return failed_covered / denom

        else:
            raise ValueError(f"Unknown formula: {formula}")

    def _get_code_snippet(
        self,
        file_content: str,
        line_num: int,
        context_lines: int = 3,
    ) -> str:
        """Get code snippet around a line.

        Args:
            file_content: Full file content.
            line_num: Line number (1-indexed).
            context_lines: Number of context lines before/after.

        Returns:
            Code snippet with context.
        """
        if not file_content:
            return ""

        lines = file_content.split("\n")
        if line_num < 1 or line_num > len(lines):
            return ""

        start = max(0, line_num - 1 - context_lines)
        end = min(len(lines), line_num + context_lines)

        snippet_lines = []
        for i in range(start, end):
            prefix = ">>> " if i == line_num - 1 else "    "
            snippet_lines.append(f"{prefix}{i + 1}: {lines[i]}")

        return "\n".join(snippet_lines)


class HybridFaultLocalizer:
    """Combines SBFL with LLM semantic understanding.

    Extended with execution grounding and run diff capabilities for
    more accurate fault localization using concrete execution data.
    """

    def __init__(
        self,
        instructor_client=None,
        sbfl_weight: float = 0.4,
        llm_weight: float = 0.6,
        diff_weight: float = 0.3,
    ):
        """Initialize hybrid localizer.

        Args:
            instructor_client: LLM client for semantic analysis.
            sbfl_weight: Weight for SBFL scores in ensemble.
            llm_weight: Weight for LLM scores in ensemble.
            diff_weight: Weight for run diff scores in ensemble.
        """
        self.sbfl = SBFLAnalyzer()
        self.instructor_client = instructor_client
        self.sbfl_weight = sbfl_weight
        self.llm_weight = llm_weight
        self.diff_weight = diff_weight

        # Initialize execution grounder if available
        self._grounder = ExecutionGrounder() if GROUNDING_AVAILABLE else None

    async def localize(
        self,
        test_results: List[TestResult],
        coverage: CoverageData,
        failure_context: FailureContext,
        sbfl_formula: str = "ochiai",
        top_k: int = 10,
    ) -> List[SuspiciousLocation]:
        """Localize fault using hybrid approach.

        Args:
            test_results: Test execution results.
            coverage: Coverage data.
            failure_context: Context about the failure.
            sbfl_formula: SBFL formula to use.
            top_k: Number of top locations to return.

        Returns:
            List of suspicious locations ranked by combined score.
        """
        # Step 1: SBFL ranking (fast, statistical)
        sbfl_ranked = self.sbfl.rank_locations(
            test_results,
            coverage,
            formula=sbfl_formula,
            top_k=30,
        )

        if not sbfl_ranked:
            return []

        # Step 2: LLM semantic re-ranking
        llm_ranked = await self._llm_rerank(sbfl_ranked, failure_context)

        # Step 3: Ensemble combination
        final_ranked = self._ensemble_combine(
            sbfl_ranked,
            llm_ranked,
        )

        return final_ranked[:top_k]

    async def _llm_rerank(
        self,
        locations: List[SuspiciousLocation],
        context: FailureContext,
        execution_evidence: Optional[Dict[str, Any]] = None,
    ) -> List[SuspiciousLocation]:
        """LLM evaluates each location's likelihood of being the fault.

        Args:
            locations: Locations to evaluate.
            context: Failure context.
            execution_evidence: Optional execution evidence including:
                - divergence_point: Where passing/failing runs diverged
                - relevant_locals: Local variables at failure point
                - io_summary: Summary of I/O operations

        Returns:
            Locations with LLM scores added.
        """
        if not self.instructor_client:
            logger.warning("No LLM client configured, skipping semantic re-ranking")
            return locations

        # Format locations for prompt
        locations_text = self._format_locations(locations[:20])  # Limit for context

        stack_trace = context.stack_trace[:500] if context.stack_trace else "N/A"

        # Build execution evidence section if available
        evidence_section = ""
        if execution_evidence:
            evidence_parts = []

            # Add divergence point information
            divergence = execution_evidence.get("divergence_point")
            if divergence:
                evidence_parts.append(f"""
EXECUTION DIVERGENCE POINT (passing vs failing run):
- Event index: {divergence.get('index', 'N/A')}
- Type: {divergence.get('type', 'N/A')}
- Description: {divergence.get('description', 'N/A')}
This is where the failing run first behaved differently from the passing run.
""")

            # Add relevant locals
            locals_data = execution_evidence.get("relevant_locals")
            if locals_data:
                variables = locals_data.get("variables", {})
                suspicious = locals_data.get("suspicious", [])
                frame = locals_data.get("frame", {})

                if variables:
                    vars_text = "\n".join(
                        f"  - {k}: {v}" for k, v in list(variables.items())[:10]
                    )
                    suspicious_text = ", ".join(suspicious) if suspicious else "None identified"
                    evidence_parts.append(f"""
LOCAL VARIABLES AT FAILURE:
Frame: {frame.get('function', 'N/A')} ({frame.get('file', 'N/A')}:{frame.get('line', 'N/A')})
Variables:
{vars_text}
Suspicious variables: {suspicious_text}
""")

            # Add I/O transcript summary
            io_summary = execution_evidence.get("io_summary")
            if io_summary:
                evidence_parts.append(f"""
I/O TRANSCRIPT SUMMARY:
{io_summary[:800]}
""")

            if evidence_parts:
                evidence_section = "\n".join(evidence_parts)

        prompt = f"""
Analyze these suspicious code locations for a {context.error_type} failure.

Error: {context.error_message}
Stack trace (last 500 chars): {stack_trace}
{evidence_section}
Suspicious locations from statistical analysis:
{locations_text}

For each location, assess:
1. Could this code directly cause the observed error?
2. Is this in the causal path of the failure?
3. Is this just logging/utility code (unlikely to be the fault)?
{"4. Does this location relate to the divergence point or suspicious variables?" if execution_evidence else ""}

Score each location from 0.0 to 1.0:
- 1.0: Very likely the fault location
- 0.7-0.9: Probably related to the fault
- 0.4-0.6: Possibly related
- 0.1-0.3: Unlikely but not impossible
- 0.0: Definitely not the fault (logging, unrelated code)

{"IMPORTANT: Locations near the divergence point or involving suspicious variables should be scored higher." if execution_evidence else ""}

Return a JSON object with scores mapping location IDs to float scores.
"""

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

            # Update locations with LLM scores
            for loc in locations:
                if loc.id in response.scores:
                    loc.llm_score = response.scores[loc.id]
                    loc.llm_reasoning = response.reasoning.get(loc.id, "")

            return sorted(locations, key=lambda x: x.llm_score, reverse=True)

        except Exception as e:
            logger.error(f"LLM re-ranking failed: {e}")
            return locations

    def _format_locations(self, locations: List[SuspiciousLocation]) -> str:
        """Format locations for LLM prompt.

        Args:
            locations: Locations to format.

        Returns:
            Formatted text.
        """
        parts = []
        for loc in locations:
            parts.append(f"""
[{loc.id}] SBFL score: {loc.sbfl_score:.3f}
Code:
{loc.code_snippet}
---
""")
        return "\n".join(parts)

    def _ensemble_combine(
        self,
        sbfl_ranked: List[SuspiciousLocation],
        llm_ranked: List[SuspiciousLocation],
    ) -> List[SuspiciousLocation]:
        """Combine SBFL and LLM rankings.

        Args:
            sbfl_ranked: SBFL-ranked locations.
            llm_ranked: LLM-ranked locations.

        Returns:
            Combined ranked list.
        """
        # Create lookup by ID
        by_id: Dict[str, SuspiciousLocation] = {}

        for loc in sbfl_ranked:
            by_id[loc.id] = loc

        for loc in llm_ranked:
            if loc.id in by_id:
                by_id[loc.id].llm_score = loc.llm_score
                by_id[loc.id].llm_reasoning = loc.llm_reasoning

        # Calculate combined scores
        for loc in by_id.values():
            # Normalize SBFL score to 0-1 range
            normalized_sbfl = loc.sbfl_score

            # Get LLM score (default to 0.5 if not available)
            llm_score = loc.llm_score if loc.llm_score > 0 else 0.5

            # Weighted combination
            loc.combined_score = (
                self.sbfl_weight * normalized_sbfl +
                self.llm_weight * llm_score
            )

        # Sort by combined score
        result = list(by_id.values())
        result.sort(key=lambda x: x.combined_score, reverse=True)

        return result

    async def localize_from_context(
        self,
        context: FailureContext,
        coverage: Optional[CoverageData] = None,
    ) -> List[SuspiciousLocation]:
        """Simplified localization using just failure context.

        When full test suite coverage isn't available, uses LLM
        to analyze the failure directly.

        Args:
            context: Failure context.
            coverage: Optional coverage data.

        Returns:
            List of suspicious locations.
        """
        if not self.instructor_client:
            logger.warning("No LLM client configured for localization")
            return []

        prompt = f"""
Analyze this test failure and identify the most likely fault locations.

Error type: {context.error_type}
Error message: {context.error_message}

Stack trace:
{context.stack_trace[:1500] if context.stack_trace else 'N/A'}

Code snippet:
{context.code_snippet[:1000] if context.code_snippet else 'N/A'}

Identify the top 5 most suspicious locations that could cause this error.
For each, provide:
1. File and line number (from stack trace or code analysis)
2. Confidence score (0-1)
3. Brief reasoning

Return as a JSON array of objects with fields: file, line, confidence, reasoning.
"""

        try:
            from typing import List as ListType

            from pydantic import BaseModel

            class LocationResult(BaseModel):
                file: str
                line: int
                confidence: float
                reasoning: str

            class LocationResults(BaseModel):
                locations: ListType[LocationResult]

            response = await self.instructor_client.generate(
                prompt,
                response_model=LocationResults,
            )

            return [
                SuspiciousLocation(
                    id=f"{loc.file}:{loc.line}",
                    file=loc.file,
                    line=loc.line,
                    code_snippet="",  # Would need file access to populate
                    llm_score=loc.confidence,
                    llm_reasoning=loc.reasoning,
                    combined_score=loc.confidence,
                )
                for loc in response.locations
            ]

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

    async def localize_with_diff(
        self,
        passing_run_id: str,
        failing_run_id: str,
        passing_run_events: List[Dict[str, Any]],
        failing_run_events: List[Dict[str, Any]],
        context: FailureContext,
        test_results: Optional[List[TestResult]] = None,
        coverage: Optional[CoverageData] = None,
        sbfl_formula: str = "ochiai",
        top_k: int = 10,
    ) -> List[SuspiciousLocation]:
        """Localize fault using run diffs as primary signal.

        Uses the divergence point between passing and failing runs to
        identify the most suspicious locations. Events only in the failing
        run are treated as secondary suspects.

        Args:
            passing_run_id: ID of the passing run.
            failing_run_id: ID of the failing run.
            passing_run_events: Events from the passing run.
            failing_run_events: Events from the failing run.
            context: Failure context.
            test_results: Optional test results for SBFL.
            coverage: Optional coverage data for SBFL.
            sbfl_formula: SBFL formula to use.
            top_k: Number of top locations to return.

        Returns:
            List of suspicious locations ranked by combined score.
        """
        if not DIFF_AVAILABLE:
            logger.warning("Run diff not available, falling back to standard localization")
            if test_results and coverage:
                return await self.localize(
                    test_results, coverage, context, sbfl_formula, top_k
                )
            return await self.localize_from_context(context, coverage)

        # Step 1: Get run diff
        try:
            diff_result = diff_runs(
                run_a_id=failing_run_id,
                run_a_status="failed",
                run_a_events=failing_run_events,
                run_b_id=passing_run_id,
                run_b_status="passed",
                run_b_events=passing_run_events,
            )
        except Exception as e:
            logger.error(f"Run diff failed: {e}")
            diff_result = None

        # Step 2: Create locations from divergence analysis
        diff_locations: List[SuspiciousLocation] = []

        if diff_result and diff_result.divergence_point:
            divergence = diff_result.divergence_point

            # Primary suspect: divergence point location
            primary_loc = self._create_divergence_location(
                divergence,
                failing_run_events,
                score=1.0,
                is_primary=True,
            )
            if primary_loc:
                diff_locations.append(primary_loc)

            # Secondary suspects: events only in failing run
            failing_only_indices = self._find_failing_only_events(
                passing_run_events,
                failing_run_events,
                divergence.index,
            )
            for idx in failing_only_indices[:5]:  # Limit secondary suspects
                event = failing_run_events[idx]
                secondary_loc = self._create_event_location(
                    event,
                    idx,
                    score=0.6,
                )
                if secondary_loc:
                    diff_locations.append(secondary_loc)

        # Step 3: Get SBFL locations if available
        sbfl_locations: List[SuspiciousLocation] = []
        if test_results and coverage:
            sbfl_locations = self.sbfl.rank_locations(
                test_results,
                coverage,
                formula=sbfl_formula,
                top_k=30,
            )

        # Step 4: Merge diff and SBFL results
        merged_locations = self._merge_diff_and_sbfl(
            diff_locations,
            sbfl_locations,
        )

        if not merged_locations:
            return await self.localize_from_context(context, coverage)

        # Step 5: LLM re-ranking with execution evidence
        execution_evidence = None
        if diff_result and diff_result.divergence_point:
            execution_evidence = {
                "divergence_point": {
                    "index": diff_result.divergence_point.index,
                    "type": diff_result.divergence_point.divergence_type.value,
                    "description": diff_result.divergence_point.description,
                },
            }

            # Add context's locals if available
            if context.locals_snapshot:
                execution_evidence["relevant_locals"] = context.locals_snapshot

            # Add transcript summary if available
            if context.transcript_summary:
                execution_evidence["io_summary"] = context.transcript_summary

        llm_ranked = await self._llm_rerank(
            merged_locations,
            context,
            execution_evidence=execution_evidence,
        )

        # Step 6: Final ensemble combination
        final_ranked = self._ensemble_combine_with_diff(
            sbfl_locations if sbfl_locations else [],
            llm_ranked,
            diff_locations,
        )

        return final_ranked[:top_k]

    async def localize_with_grounding(
        self,
        context: FailureContext,
        passing_run_events: Optional[List[Dict[str, Any]]] = None,
        failing_run_events: Optional[List[Dict[str, Any]]] = None,
        locals_snapshot: Optional[Any] = None,
        test_results: Optional[List[TestResult]] = None,
        coverage: Optional[CoverageData] = None,
        sbfl_formula: str = "ochiai",
        top_k: int = 10,
    ) -> List[SuspiciousLocation]:
        """Localize fault using ExecutionGrounder for grounded evidence.

        Uses the ExecutionGrounder to extract concrete evidence from
        execution traces, then passes this evidence to the LLM for
        more accurate localization.

        Args:
            context: Failure context.
            passing_run_events: Events from a passing run (optional).
            failing_run_events: Events from the failing run (optional).
            locals_snapshot: Captured local variables at failure.
            test_results: Optional test results for SBFL.
            coverage: Optional coverage data for SBFL.
            sbfl_formula: SBFL formula to use.
            top_k: Number of top locations to return.

        Returns:
            List of suspicious locations ranked by combined score.
        """
        if not GROUNDING_AVAILABLE or not self._grounder:
            logger.warning(
                "ExecutionGrounder not available, falling back to standard localization"
            )
            if test_results and coverage:
                return await self.localize(
                    test_results, coverage, context, sbfl_formula, top_k
                )
            return await self.localize_from_context(context, coverage)

        # Step 1: Get grounded evidence
        try:
            evidence: GroundedEvidence = self._grounder.ground_with_traces(
                failure_context=context,
                passing_run_events=passing_run_events,
                failing_run_events=failing_run_events,
                locals_snapshot=locals_snapshot,
            )
        except Exception as e:
            logger.error(f"Execution grounding failed: {e}")
            evidence = None

        # Step 2: Get SBFL locations if available
        sbfl_locations: List[SuspiciousLocation] = []
        if test_results and coverage:
            sbfl_locations = self.sbfl.rank_locations(
                test_results,
                coverage,
                formula=sbfl_formula,
                top_k=30,
            )

        # Step 3: Create locations from grounded evidence
        grounded_locations: List[SuspiciousLocation] = []
        if evidence:
            # Add location from divergence point
            if evidence.divergence:
                div_loc = self._create_divergence_location(
                    evidence.divergence,
                    failing_run_events or [],
                    score=1.0,
                    is_primary=True,
                )
                if div_loc:
                    grounded_locations.append(div_loc)

            # Add location from locals frame
            if evidence.relevant_locals:
                frame = evidence.relevant_locals.get("frame", {})
                if frame.get("file") and frame.get("line"):
                    locals_loc = SuspiciousLocation(
                        id=f"{frame['file']}:{frame['line']}",
                        file=frame["file"],
                        line=frame["line"],
                        function_name=frame.get("function"),
                        code_snippet="",
                        llm_score=0.8,
                        llm_reasoning="Frame with suspicious local variables at failure point",
                    )
                    grounded_locations.append(locals_loc)

        # Step 4: Merge all location sources
        all_locations = self._merge_diff_and_sbfl(
            grounded_locations,
            sbfl_locations,
        )

        if not all_locations:
            return await self.localize_from_context(context, coverage)

        # Step 5: Prepare execution evidence for LLM
        execution_evidence = None
        if evidence:
            execution_evidence = {}

            if evidence.divergence:
                execution_evidence["divergence_point"] = {
                    "index": evidence.divergence.index,
                    "type": evidence.divergence.divergence_type.value,
                    "description": evidence.divergence.description,
                }

            if evidence.relevant_locals:
                execution_evidence["relevant_locals"] = evidence.relevant_locals

            if evidence.io_summary:
                execution_evidence["io_summary"] = evidence.io_summary

        # Step 6: LLM re-ranking with grounded evidence
        llm_ranked = await self._llm_rerank(
            all_locations,
            context,
            execution_evidence=execution_evidence,
        )

        # Step 7: Final ensemble with confidence boost from grounding
        final_ranked = self._ensemble_combine_with_diff(
            sbfl_locations if sbfl_locations else [],
            llm_ranked,
            grounded_locations,
        )

        # Apply confidence boost from grounding
        if evidence and evidence.confidence_boost > 0:
            for loc in final_ranked:
                # Boost scores for locations that match grounded evidence
                for grounded in grounded_locations:
                    if loc.id == grounded.id:
                        loc.combined_score = min(
                            1.0,
                            loc.combined_score + evidence.confidence_boost,
                        )
                        break

        # Re-sort after boosting
        final_ranked.sort(key=lambda x: x.combined_score, reverse=True)

        return final_ranked[:top_k]

    def _create_divergence_location(
        self,
        divergence: Any,  # DivergencePoint
        events: List[Dict[str, Any]],
        score: float,
        is_primary: bool = False,
    ) -> Optional[SuspiciousLocation]:
        """Create a SuspiciousLocation from a divergence point.

        Args:
            divergence: DivergencePoint object.
            events: Event list for context.
            score: Initial score for this location.
            is_primary: Whether this is the primary divergence point.

        Returns:
            SuspiciousLocation or None if cannot be created.
        """
        if not divergence:
            return None

        # Try to extract file/line from divergence event
        event = divergence.left_event or {}
        file_path = event.get("file", event.get("source_file", "unknown"))
        line_num = event.get("line", event.get("lineno", 0))

        # Generate ID
        loc_id = f"divergence:{divergence.index}"
        if file_path != "unknown" and line_num > 0:
            loc_id = f"{file_path}:{line_num}"

        # Build code snippet from event context
        snippet = self._format_event_as_snippet(event, divergence)

        reasoning = f"Divergence point: {divergence.description}"
        if is_primary:
            reasoning = f"PRIMARY DIVERGENCE: {divergence.description}"

        return SuspiciousLocation(
            id=loc_id,
            file=file_path,
            line=line_num,
            code_snippet=snippet,
            llm_score=score,
            llm_reasoning=reasoning,
            combined_score=score,
        )

    def _create_event_location(
        self,
        event: Dict[str, Any],
        index: int,
        score: float,
    ) -> Optional[SuspiciousLocation]:
        """Create a SuspiciousLocation from an event.

        Args:
            event: Event dictionary.
            index: Event index.
            score: Initial score.

        Returns:
            SuspiciousLocation or None if cannot be created.
        """
        event_type = event.get("type", "UNKNOWN")
        file_path = event.get("file", event.get("source_file", "unknown"))
        line_num = event.get("line", event.get("lineno", 0))

        loc_id = f"event:{index}"
        if file_path != "unknown" and line_num > 0:
            loc_id = f"{file_path}:{line_num}"

        snippet = self._format_event_as_snippet(event, None)

        return SuspiciousLocation(
            id=loc_id,
            file=file_path,
            line=line_num,
            code_snippet=snippet,
            llm_score=score,
            llm_reasoning=f"Event #{index} ({event_type}) only in failing run",
            combined_score=score,
        )

    def _find_failing_only_events(
        self,
        passing_events: List[Dict[str, Any]],
        failing_events: List[Dict[str, Any]],
        divergence_index: int,
    ) -> List[int]:
        """Find event indices that only appear in the failing run.

        Args:
            passing_events: Events from passing run.
            failing_events: Events from failing run.
            divergence_index: Index where divergence occurred.

        Returns:
            List of event indices unique to failing run.
        """
        failing_only: List[int] = []

        # Look at events after divergence point
        for i in range(divergence_index + 1, len(failing_events)):
            if i >= len(passing_events):
                # Event beyond passing run length
                failing_only.append(i)
            else:
                # Check if events differ
                pass_event = passing_events[i]
                fail_event = failing_events[i]

                if pass_event.get("type") != fail_event.get("type"):
                    failing_only.append(i)
                elif self._events_differ_significantly(pass_event, fail_event):
                    failing_only.append(i)

        return failing_only

    def _events_differ_significantly(
        self,
        event_a: Dict[str, Any],
        event_b: Dict[str, Any],
    ) -> bool:
        """Check if two events differ significantly.

        Args:
            event_a: First event.
            event_b: Second event.

        Returns:
            True if events differ significantly.
        """
        # Check key fields that indicate significant difference
        key_fields = ["status", "response", "rowcount", "error"]

        for field in key_fields:
            if event_a.get(field) != event_b.get(field):
                return True

        return False

    def _format_event_as_snippet(
        self,
        event: Dict[str, Any],
        divergence: Optional[Any],
    ) -> str:
        """Format an event as a code-like snippet.

        Args:
            event: Event dictionary.
            divergence: Optional divergence info.

        Returns:
            Formatted snippet string.
        """
        event_type = event.get("type", "UNKNOWN")
        lines = [f"# Event type: {event_type}"]

        if event_type == "HTTP":
            method = event.get("method", "?")
            url = event.get("url", "?")
            status = event.get("status", "?")
            lines.append(f"# {method} {url}")
            lines.append(f"# Response status: {status}")

        elif event_type == "SQL":
            query = event.get("query", "?")[:200]
            rowcount = event.get("rowcount", "?")
            lines.append(f"# Query: {query}")
            lines.append(f"# Rows: {rowcount}")

        elif event_type == "REDIS":
            cmd = event.get("command", "?")
            key = event.get("key", "?")
            lines.append(f"# {cmd} {key}")

        if divergence and divergence.field_diffs:
            lines.append("# Field differences:")
            for diff in divergence.field_diffs[:3]:
                lines.append(f"#   {diff.path}: {diff.description}")

        return "\n".join(lines)

    def _merge_diff_and_sbfl(
        self,
        diff_locations: List[SuspiciousLocation],
        sbfl_locations: List[SuspiciousLocation],
    ) -> List[SuspiciousLocation]:
        """Merge diff-based and SBFL-based locations.

        Args:
            diff_locations: Locations from diff analysis.
            sbfl_locations: Locations from SBFL analysis.

        Returns:
            Merged list of locations.
        """
        by_id: Dict[str, SuspiciousLocation] = {}

        # Add diff locations first (higher priority)
        for loc in diff_locations:
            by_id[loc.id] = loc

        # Add SBFL locations
        for loc in sbfl_locations:
            if loc.id not in by_id:
                by_id[loc.id] = loc
            else:
                # Merge scores
                existing = by_id[loc.id]
                existing.sbfl_score = loc.sbfl_score
                existing.sbfl_formula = loc.sbfl_formula
                existing.pass_count = loc.pass_count
                existing.fail_count = loc.fail_count

        return list(by_id.values())

    def _ensemble_combine_with_diff(
        self,
        sbfl_ranked: List[SuspiciousLocation],
        llm_ranked: List[SuspiciousLocation],
        diff_ranked: List[SuspiciousLocation],
    ) -> List[SuspiciousLocation]:
        """Combine SBFL, LLM, and diff rankings.

        Uses configurable weights (sbfl_weight, llm_weight, diff_weight)
        to combine scores from all three sources.

        Args:
            sbfl_ranked: SBFL-ranked locations.
            llm_ranked: LLM-ranked locations.
            diff_ranked: Diff-ranked locations.

        Returns:
            Combined ranked list.
        """
        # Normalize weights
        total_weight = self.sbfl_weight + self.llm_weight + self.diff_weight
        sbfl_w = self.sbfl_weight / total_weight
        llm_w = self.llm_weight / total_weight
        diff_w = self.diff_weight / total_weight

        # Create lookup by ID
        by_id: Dict[str, SuspiciousLocation] = {}

        # Collect all locations
        for loc in sbfl_ranked + llm_ranked + diff_ranked:
            if loc.id not in by_id:
                by_id[loc.id] = SuspiciousLocation(
                    id=loc.id,
                    file=loc.file,
                    line=loc.line,
                    function_name=loc.function_name,
                    code_snippet=loc.code_snippet,
                    sbfl_score=0.0,
                    llm_score=0.0,
                    combined_score=0.0,
                )

        # Aggregate SBFL scores
        for loc in sbfl_ranked:
            if loc.id in by_id:
                by_id[loc.id].sbfl_score = loc.sbfl_score
                by_id[loc.id].sbfl_formula = loc.sbfl_formula
                by_id[loc.id].pass_count = loc.pass_count
                by_id[loc.id].fail_count = loc.fail_count

        # Aggregate LLM scores
        for loc in llm_ranked:
            if loc.id in by_id:
                by_id[loc.id].llm_score = loc.llm_score
                by_id[loc.id].llm_reasoning = loc.llm_reasoning

        # Aggregate diff scores (stored in combined_score temporarily)
        diff_scores: Dict[str, float] = {}
        for loc in diff_ranked:
            diff_scores[loc.id] = loc.combined_score

        # Calculate final combined scores
        for loc in by_id.values():
            normalized_sbfl = loc.sbfl_score
            llm_score = loc.llm_score if loc.llm_score > 0 else 0.5
            diff_score = diff_scores.get(loc.id, 0.0)

            loc.combined_score = (
                sbfl_w * normalized_sbfl +
                llm_w * llm_score +
                diff_w * diff_score
            )

        # Sort by combined score
        result = list(by_id.values())
        result.sort(key=lambda x: x.combined_score, reverse=True)

        return result
