"""Execution Grounding for LLM-First Debugging.

This module grounds LLM reasoning with actual execution traces,
finding divergence points between passing and failing runs,
extracting relevant local variables, and summarizing I/O transcripts.

Key capabilities:
- Ground LLM hypotheses with concrete execution data
- Compare passing vs failing runs to find divergence
- Extract and filter local variables at failure points
- Summarize HTTP/SQL/Redis transcripts for context
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

# Import diff utilities
from api.diff import (
    DivergencePoint,
    DivergenceType,
    diff_runs,
)
from autodebug.llm_first.models import (
    FailureContext,
    FlakinessCategory,
)

# Import probe utilities for captured events
try:
    from autodebug_probe.locals_capture import (
        FrameLocals,
        LocalsSnapshot,
        LocalVariable,
        snapshot_to_dict,
    )
    from autodebug_probe.recorder import (
        Recorder,
        RecordingMode,
        _load_jsonl_zst,
    )
    PROBE_AVAILABLE = True
except ImportError:
    PROBE_AVAILABLE = False
    LocalsSnapshot = None
    FrameLocals = None
    LocalVariable = None

logger = logging.getLogger("autodebug.llm_first.execution_grounding")


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

@dataclass
class GroundedEvidence:
    """Evidence grounded in actual execution data.

    Attributes:
        divergence: The divergence point between runs (if found)
        relevant_locals: Local variables at the divergence point
        io_summary: Summary of I/O operations
        event_timeline: Timeline of significant events
        confidence_boost: How much this evidence boosts confidence
    """

    divergence: Optional[DivergencePoint] = None
    relevant_locals: Dict[str, Any] = field(default_factory=dict)
    io_summary: str = ""
    event_timeline: List[Dict[str, Any]] = field(default_factory=list)
    confidence_boost: float = 0.0


@dataclass
class IOTranscriptSummary:
    """Summary of I/O transcript for a run.

    Attributes:
        http_calls: Summary of HTTP calls
        sql_queries: Summary of SQL queries
        redis_ops: Summary of Redis operations
        total_duration_ms: Total I/O duration
        key_observations: Important observations from the transcript
    """

    http_calls: List[Dict[str, Any]] = field(default_factory=list)
    sql_queries: List[Dict[str, Any]] = field(default_factory=list)
    redis_ops: List[Dict[str, Any]] = field(default_factory=list)
    total_duration_ms: float = 0.0
    key_observations: List[str] = field(default_factory=list)


@dataclass
class LocalsAtDivergence:
    """Local variables captured at the divergence point.

    Attributes:
        frame_function: Function name where divergence occurred
        frame_file: File where divergence occurred
        frame_line: Line number
        variables: Dict of variable name to value
        suspicious_variables: Variables that may be related to the divergence
    """

    frame_function: str = ""
    frame_file: str = ""
    frame_line: int = 0
    variables: Dict[str, str] = field(default_factory=dict)
    suspicious_variables: List[str] = field(default_factory=list)


# =============================================================================
# Execution Grounder
# =============================================================================

class ExecutionGrounder:
    """Grounds LLM reasoning with actual execution traces.

    This class bridges the gap between LLM hypotheses and concrete
    execution data, providing evidence from:
    - Run divergence analysis
    - Local variable capture
    - I/O transcript analysis

    Example usage:
        grounder = ExecutionGrounder()

        # Ground a failure context with execution traces
        evidence = grounder.ground_with_traces(
            failure_context=context,
            passing_run_events=pass_events,
            failing_run_events=fail_events,
        )

        # Find divergence between runs
        divergence = grounder.find_divergence_point(
            passing_events=pass_events,
            failing_events=fail_events,
        )

        # Extract locals at failure
        locals_data = grounder.extract_relevant_locals(
            snapshot=locals_snapshot,
            divergence=divergence,
        )
    """

    def __init__(
        self,
        max_locals_per_frame: int = 20,
        max_io_events: int = 50,
        max_summary_length: int = 2000,
    ) -> None:
        """Initialize the ExecutionGrounder.

        Args:
            max_locals_per_frame: Maximum local variables to extract per frame
            max_io_events: Maximum I/O events to include in summary
            max_summary_length: Maximum length of text summaries
        """
        self.max_locals_per_frame = max_locals_per_frame
        self.max_io_events = max_io_events
        self.max_summary_length = max_summary_length

        # Categories where execution grounding is most valuable
        self._grounding_priority = {
            FlakinessCategory.TIMING: 0.9,
            FlakinessCategory.ORDERING: 0.85,
            FlakinessCategory.STATE: 0.8,
            FlakinessCategory.NETWORK: 0.75,
            FlakinessCategory.CONCURRENCY: 0.7,
            FlakinessCategory.RESOURCE: 0.6,
            FlakinessCategory.UNKNOWN: 0.5,
        }

    def ground_with_traces(
        self,
        failure_context: FailureContext,
        passing_run_events: Optional[List[Dict[str, Any]]] = None,
        failing_run_events: Optional[List[Dict[str, Any]]] = None,
        passing_run_id: str = "pass",
        failing_run_id: str = "fail",
        locals_snapshot: Optional[Any] = None,
    ) -> GroundedEvidence:
        """Ground LLM reasoning with actual execution traces.

        This method combines divergence analysis, local variable extraction,
        and I/O transcript summarization to provide concrete evidence
        that supports or refutes LLM hypotheses.

        Args:
            failure_context: The failure context from LLM analysis
            passing_run_events: Events from a passing run (optional)
            failing_run_events: Events from the failing run
            passing_run_id: ID of the passing run
            failing_run_id: ID of the failing run
            locals_snapshot: Captured local variables at failure

        Returns:
            GroundedEvidence with execution-grounded data
        """
        logger.debug(
            "Grounding failure context for test '%s' with execution traces",
            failure_context.test_name,
        )

        evidence = GroundedEvidence()
        confidence_factors: List[float] = []

        # 1. Find divergence point if we have both passing and failing runs
        if passing_run_events and failing_run_events:
            try:
                divergence = self.find_divergence_point(
                    passing_events=passing_run_events,
                    failing_events=failing_run_events,
                    passing_run_id=passing_run_id,
                    failing_run_id=failing_run_id,
                )
                evidence.divergence = divergence

                if divergence:
                    logger.info(
                        "Found divergence at event %d: %s",
                        divergence.index,
                        divergence.description,
                    )
                    confidence_factors.append(0.3)

                    # Build event timeline around divergence
                    evidence.event_timeline = self._build_event_timeline(
                        failing_events=failing_run_events,
                        divergence_index=divergence.index,
                        window_size=5,
                    )
            except Exception as e:
                logger.warning("Failed to find divergence point: %s", e)

        # 2. Extract relevant local variables
        if locals_snapshot:
            try:
                locals_data = self.extract_relevant_locals(
                    snapshot=locals_snapshot,
                    divergence=evidence.divergence,
                    failure_context=failure_context,
                )
                evidence.relevant_locals = {
                    "frame": {
                        "function": locals_data.frame_function,
                        "file": locals_data.frame_file,
                        "line": locals_data.frame_line,
                    },
                    "variables": locals_data.variables,
                    "suspicious": locals_data.suspicious_variables,
                }

                if locals_data.suspicious_variables:
                    confidence_factors.append(0.2)
            except Exception as e:
                logger.warning("Failed to extract local variables: %s", e)

        # 3. Summarize I/O transcript
        if failing_run_events:
            try:
                io_summary = self.summarize_io_transcript(
                    events=failing_run_events,
                    focus_around_index=evidence.divergence.index if evidence.divergence else None,
                )
                evidence.io_summary = self._format_io_summary(io_summary)

                if io_summary.key_observations:
                    confidence_factors.append(0.15)
            except Exception as e:
                logger.warning("Failed to summarize I/O transcript: %s", e)

        # 4. Calculate confidence boost based on grounding quality
        if confidence_factors:
            category = failure_context.category or FlakinessCategory.UNKNOWN
            category_weight = self._grounding_priority.get(category, 0.5)
            evidence.confidence_boost = sum(confidence_factors) * category_weight

        logger.debug(
            "Grounding complete with confidence boost: %.2f",
            evidence.confidence_boost,
        )

        return evidence

    def find_divergence_point(
        self,
        passing_events: List[Dict[str, Any]],
        failing_events: List[Dict[str, Any]],
        passing_run_id: str = "pass",
        failing_run_id: str = "fail",
    ) -> Optional[DivergencePoint]:
        """Find the divergence point between passing and failing runs.

        Compares two execution traces to find where they first diverge,
        which often indicates the root cause of flakiness.

        Args:
            passing_events: Events from a passing run
            failing_events: Events from a failing run
            passing_run_id: ID of the passing run
            failing_run_id: ID of the failing run

        Returns:
            DivergencePoint if found, None otherwise
        """
        if not passing_events or not failing_events:
            logger.debug("Cannot find divergence: missing event data")
            return None

        logger.debug(
            "Comparing %d passing events with %d failing events",
            len(passing_events),
            len(failing_events),
        )

        try:
            diff_result = diff_runs(
                run_a_id=failing_run_id,
                run_a_status="failed",
                run_a_events=failing_events,
                run_b_id=passing_run_id,
                run_b_status="passed",
                run_b_events=passing_events,
            )

            if diff_result.divergence_point:
                logger.info(
                    "Divergence found: %s at index %d",
                    diff_result.divergence_point.divergence_type.value,
                    diff_result.divergence_point.index,
                )
                return diff_result.divergence_point

            logger.debug("No significant divergence found between runs")
            return None

        except Exception as e:
            logger.error("Error during divergence analysis: %s", e)
            raise

    def extract_relevant_locals(
        self,
        snapshot: Any,
        divergence: Optional[DivergencePoint] = None,
        failure_context: Optional[FailureContext] = None,
    ) -> LocalsAtDivergence:
        """Extract relevant local variables at the divergence point.

        Filters local variables to focus on those most relevant to
        the failure, based on divergence analysis and failure context.

        Args:
            snapshot: LocalsSnapshot from autodebug_probe
            divergence: Divergence point (if known)
            failure_context: Failure context for additional filtering

        Returns:
            LocalsAtDivergence with filtered variables
        """
        result = LocalsAtDivergence()

        if not PROBE_AVAILABLE:
            logger.warning("Probe not available, cannot extract locals")
            return result

        if snapshot is None:
            logger.debug("No locals snapshot provided")
            return result

        # Get the most relevant frame (typically the failure location)
        target_frame = self._find_target_frame(snapshot, failure_context)

        if target_frame is None:
            logger.debug("No relevant frame found in snapshot")
            return result

        result.frame_function = target_frame.function
        result.frame_file = target_frame.filename
        result.frame_line = target_frame.lineno

        # Extract variables, limiting count
        variables_extracted = 0
        for var in target_frame.variables:
            if variables_extracted >= self.max_locals_per_frame:
                break

            if var.is_redacted:
                result.variables[var.name] = "<redacted>"
            else:
                result.variables[var.name] = var.value

            variables_extracted += 1

        # Identify suspicious variables based on divergence and context
        result.suspicious_variables = self._identify_suspicious_variables(
            variables=result.variables,
            divergence=divergence,
            failure_context=failure_context,
        )

        logger.debug(
            "Extracted %d variables from frame '%s', %d suspicious",
            len(result.variables),
            result.frame_function,
            len(result.suspicious_variables),
        )

        return result

    def summarize_io_transcript(
        self,
        events: List[Dict[str, Any]],
        focus_around_index: Optional[int] = None,
    ) -> IOTranscriptSummary:
        """Summarize I/O transcript from captured events.

        Creates a human-readable summary of HTTP, SQL, and Redis
        operations, focusing on events around the divergence point.

        Args:
            events: List of captured I/O events
            focus_around_index: Event index to focus around (optional)

        Returns:
            IOTranscriptSummary with categorized I/O operations
        """
        summary = IOTranscriptSummary()

        if not events:
            logger.debug("No events to summarize")
            return summary

        # Determine event window
        if focus_around_index is not None:
            window_start = max(0, focus_around_index - 10)
            window_end = min(len(events), focus_around_index + 10)
            focused_events = events[window_start:window_end]
        else:
            focused_events = events[:self.max_io_events]

        total_duration = 0.0

        for event in focused_events:
            event_type = event.get("type", "").upper()

            if event_type == "HTTP":
                http_info = self._extract_http_info(event)
                summary.http_calls.append(http_info)
                total_duration += event.get("duration_ms", 0) or 0

            elif event_type == "SQL":
                sql_info = self._extract_sql_info(event)
                summary.sql_queries.append(sql_info)
                total_duration += event.get("duration_ms", 0) or 0

            elif event_type == "REDIS":
                redis_info = self._extract_redis_info(event)
                summary.redis_ops.append(redis_info)
                total_duration += event.get("duration_ms", 0) or 0

        summary.total_duration_ms = total_duration

        # Generate key observations
        summary.key_observations = self._generate_io_observations(summary)

        logger.debug(
            "Summarized %d HTTP, %d SQL, %d Redis ops (%.2fms total)",
            len(summary.http_calls),
            len(summary.sql_queries),
            len(summary.redis_ops),
            summary.total_duration_ms,
        )

        return summary

    def load_events_from_run_dir(
        self,
        run_dir: Union[str, Path],
        event_type: str = "all",
    ) -> List[Dict[str, Any]]:
        """Load recorded events from a run directory.

        Args:
            run_dir: Path to the run directory
            event_type: Type of events to load ('http', 'sql', 'redis', 'all')

        Returns:
            List of event dicts

        Raises:
            FileNotFoundError: If run directory doesn't exist
        """
        run_path = Path(run_dir)
        if not run_path.exists():
            raise FileNotFoundError(f"Run directory not found: {run_dir}")

        if not PROBE_AVAILABLE:
            logger.warning("Probe not available, using basic JSON loading")
            return self._load_events_basic(run_path, event_type)

        events: List[Dict[str, Any]] = []

        type_to_file = {
            "http": "deps_http.jsonl.zst",
            "sql": "deps_sql.jsonl.zst",
            "redis": "deps_redis.jsonl.zst",
            "events": "events.jsonl.zst",
        }

        if event_type == "all":
            for file_type, filename in type_to_file.items():
                file_path = run_path / filename
                if file_path.exists():
                    try:
                        events.extend(_load_jsonl_zst(file_path))
                    except Exception as e:
                        logger.warning("Failed to load %s: %s", filename, e)
        else:
            filename = type_to_file.get(event_type)
            if filename:
                file_path = run_path / filename
                if file_path.exists():
                    events = _load_jsonl_zst(file_path)

        # Sort events by sequence number
        events.sort(key=lambda e: e.get("seq", 0))

        logger.debug("Loaded %d events from %s", len(events), run_dir)
        return events

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

    def _find_target_frame(
        self,
        snapshot: Any,
        failure_context: Optional[FailureContext],
    ) -> Optional[Any]:
        """Find the most relevant frame in a locals snapshot.

        Args:
            snapshot: LocalsSnapshot object
            failure_context: Failure context for matching

        Returns:
            FrameLocals object or None
        """
        if not hasattr(snapshot, "frames") or not snapshot.frames:
            return None

        # If we have failure context, try to match by file/line
        if failure_context and failure_context.line_number:
            for frame in snapshot.frames:
                if (
                    failure_context.test_file in frame.filename
                    and frame.lineno == failure_context.line_number
                ):
                    return frame

        # If we have function name, try to match
        if failure_context and failure_context.function_name:
            for frame in snapshot.frames:
                if frame.function == failure_context.function_name:
                    return frame

        # Default to the innermost (first) frame
        return snapshot.frames[0] if snapshot.frames else None

    def _identify_suspicious_variables(
        self,
        variables: Dict[str, str],
        divergence: Optional[DivergencePoint],
        failure_context: Optional[FailureContext],
    ) -> List[str]:
        """Identify variables that may be related to the failure.

        Args:
            variables: Dict of variable name to string value
            divergence: Divergence point (if known)
            failure_context: Failure context

        Returns:
            List of suspicious variable names
        """
        suspicious: List[str] = []

        # Keywords that often relate to flakiness
        flaky_keywords = [
            "time", "timestamp", "date", "now",
            "random", "rand", "uuid", "id",
            "count", "counter", "index", "i",
            "retry", "attempt", "timeout",
            "response", "result", "status",
            "error", "exception", "message",
            "order", "sort", "sequence",
        ]

        for var_name, var_value in variables.items():
            var_lower = var_name.lower()

            # Check against flaky keywords
            for keyword in flaky_keywords:
                if keyword in var_lower:
                    suspicious.append(var_name)
                    break

            # Check if variable value appears in error message
            if failure_context and failure_context.error_message:
                if var_value in failure_context.error_message:
                    if var_name not in suspicious:
                        suspicious.append(var_name)

        # If we have divergence info, look for related variables
        if divergence:
            divergence_keywords = self._extract_divergence_keywords(divergence)
            for var_name in variables:
                for keyword in divergence_keywords:
                    if keyword in var_name.lower():
                        if var_name not in suspicious:
                            suspicious.append(var_name)

        return suspicious

    def _extract_divergence_keywords(
        self,
        divergence: DivergencePoint,
    ) -> List[str]:
        """Extract keywords from divergence point for variable matching.

        Args:
            divergence: DivergencePoint object

        Returns:
            List of keywords
        """
        keywords: List[str] = []

        # Extract from divergence type
        type_keywords = {
            DivergenceType.HTTP_STATUS_DIFF: ["status", "response", "http"],
            DivergenceType.HTTP_BODY_DIFF: ["body", "response", "data"],
            DivergenceType.SQL_RESULT_DIFF: ["rows", "result", "query"],
            DivergenceType.SQL_ROWCOUNT_DIFF: ["count", "rows", "query"],
            DivergenceType.REDIS_VALUE_DIFF: ["cache", "redis", "value"],
            DivergenceType.EVENT_COUNT_DIFF: ["count", "events"],
            DivergenceType.EVENT_ORDER_DIFF: ["order", "sequence"],
        }

        keywords.extend(type_keywords.get(divergence.divergence_type, []))

        # Extract from field diffs
        for field_diff in divergence.field_diffs:
            # Parse path like "$.response.data.items"
            path_parts = field_diff.path.replace("$.", "").split(".")
            keywords.extend([p.lower() for p in path_parts if p])

        return list(set(keywords))

    def _build_event_timeline(
        self,
        failing_events: List[Dict[str, Any]],
        divergence_index: int,
        window_size: int = 5,
    ) -> List[Dict[str, Any]]:
        """Build a timeline of events around the divergence point.

        Args:
            failing_events: All events from the failing run
            divergence_index: Index of the divergence event
            window_size: Number of events before/after to include

        Returns:
            List of timeline entries
        """
        start = max(0, divergence_index - window_size)
        end = min(len(failing_events), divergence_index + window_size + 1)

        timeline = []
        for i in range(start, end):
            event = failing_events[i]
            entry = {
                "index": i,
                "type": event.get("type", "UNKNOWN"),
                "timestamp": event.get("ts"),
                "is_divergence": i == divergence_index,
            }

            # Add type-specific summary
            event_type = event.get("type", "").upper()
            if event_type == "HTTP":
                entry["summary"] = f"{event.get('method', '?')} {event.get('url', '?')}"
                entry["status"] = event.get("status")
            elif event_type == "SQL":
                query = event.get("query", "")[:100]
                entry["summary"] = query
                entry["rowcount"] = event.get("rowcount")
            elif event_type == "REDIS":
                entry["summary"] = f"{event.get('command', '?')} {event.get('key', '?')}"

            timeline.append(entry)

        return timeline

    def _extract_http_info(self, event: Dict[str, Any]) -> Dict[str, Any]:
        """Extract summarized HTTP info from an event.

        Args:
            event: HTTP event dict

        Returns:
            Summarized HTTP info dict
        """
        return {
            "method": event.get("method", "?"),
            "url": self._truncate_url(event.get("url", "")),
            "status": event.get("status"),
            "duration_ms": event.get("duration_ms"),
            "auth_type": event.get("auth_type"),
            "seq": event.get("seq"),
        }

    def _extract_sql_info(self, event: Dict[str, Any]) -> Dict[str, Any]:
        """Extract summarized SQL info from an event.

        Args:
            event: SQL event dict

        Returns:
            Summarized SQL info dict
        """
        query = event.get("query", "")
        # Truncate and normalize query
        normalized = " ".join(query.split())[:200]

        return {
            "query": normalized,
            "rowcount": event.get("rowcount"),
            "duration_ms": event.get("duration_ms"),
            "seq": event.get("seq"),
        }

    def _extract_redis_info(self, event: Dict[str, Any]) -> Dict[str, Any]:
        """Extract summarized Redis info from an event.

        Args:
            event: Redis event dict

        Returns:
            Summarized Redis info dict
        """
        return {
            "command": event.get("command", "?"),
            "key": event.get("key", ""),
            "response_type": type(event.get("response")).__name__,
            "duration_ms": event.get("duration_ms"),
            "seq": event.get("seq"),
        }

    def _generate_io_observations(
        self,
        summary: IOTranscriptSummary,
    ) -> List[str]:
        """Generate key observations from I/O summary.

        Args:
            summary: IOTranscriptSummary object

        Returns:
            List of observation strings
        """
        observations: List[str] = []

        # HTTP observations
        if summary.http_calls:
            statuses = [h.get("status") for h in summary.http_calls if h.get("status")]
            failed_calls = [s for s in statuses if s and s >= 400]
            if failed_calls:
                observations.append(
                    f"HTTP: {len(failed_calls)}/{len(statuses)} calls returned error status"
                )

            # Check for slow calls
            slow_calls = [h for h in summary.http_calls if (h.get("duration_ms") or 0) > 1000]
            if slow_calls:
                observations.append(
                    f"HTTP: {len(slow_calls)} call(s) took >1s"
                )

        # SQL observations
        if summary.sql_queries:
            # Look for queries without ORDER BY that return multiple rows
            for q in summary.sql_queries:
                query_lower = q.get("query", "").lower()
                rowcount = q.get("rowcount", 0) or 0
                if "select" in query_lower and rowcount > 1:
                    if "order by" not in query_lower:
                        observations.append(
                            "SQL: SELECT returning multiple rows without ORDER BY (non-deterministic)"
                        )
                        break

        # Redis observations
        if summary.redis_ops:
            ttl_cmds = [r for r in summary.redis_ops if r.get("command") in ("SETEX", "EXPIRE")]
            if ttl_cmds:
                observations.append(
                    f"Redis: {len(ttl_cmds)} operations with TTL (potential timing sensitivity)"
                )

        # Overall duration observation
        if summary.total_duration_ms > 5000:
            observations.append(
                f"Total I/O duration: {summary.total_duration_ms:.0f}ms (high)"
            )

        return observations

    def _format_io_summary(self, summary: IOTranscriptSummary) -> str:
        """Format IOTranscriptSummary as a human-readable string.

        Args:
            summary: IOTranscriptSummary object

        Returns:
            Formatted string summary
        """
        lines: List[str] = []

        # HTTP summary
        if summary.http_calls:
            lines.append(f"HTTP Calls ({len(summary.http_calls)}):")
            for call in summary.http_calls[:5]:  # Limit to first 5
                status = call.get("status", "?")
                lines.append(f"  - {call['method']} {call['url']} -> {status}")
            if len(summary.http_calls) > 5:
                lines.append(f"  ... and {len(summary.http_calls) - 5} more")

        # SQL summary
        if summary.sql_queries:
            lines.append(f"SQL Queries ({len(summary.sql_queries)}):")
            for query in summary.sql_queries[:3]:  # Limit to first 3
                q = query.get("query", "")[:80]
                rows = query.get("rowcount", "?")
                lines.append(f"  - {q}... ({rows} rows)")
            if len(summary.sql_queries) > 3:
                lines.append(f"  ... and {len(summary.sql_queries) - 3} more")

        # Redis summary
        if summary.redis_ops:
            lines.append(f"Redis Operations ({len(summary.redis_ops)}):")
            for op in summary.redis_ops[:3]:
                lines.append(f"  - {op['command']} {op.get('key', '')}")
            if len(summary.redis_ops) > 3:
                lines.append(f"  ... and {len(summary.redis_ops) - 3} more")

        # Key observations
        if summary.key_observations:
            lines.append("Key Observations:")
            for obs in summary.key_observations:
                lines.append(f"  * {obs}")

        result = "\n".join(lines)

        # Truncate if too long
        if len(result) > self.max_summary_length:
            result = result[: self.max_summary_length - 20] + "\n... (truncated)"

        return result

    def _truncate_url(self, url: str, max_length: int = 80) -> str:
        """Truncate URL for display.

        Args:
            url: URL string
            max_length: Maximum length

        Returns:
            Truncated URL
        """
        if len(url) <= max_length:
            return url

        # Try to keep the host and truncate the path
        try:
            from urllib.parse import urlparse
            parsed = urlparse(url)
            base = f"{parsed.scheme}://{parsed.netloc}"
            remaining = max_length - len(base) - 3
            if remaining > 10:
                return f"{base}{parsed.path[:remaining]}..."
        except Exception:
            pass

        return url[:max_length - 3] + "..."

    def _load_events_basic(
        self,
        run_path: Path,
        event_type: str,
    ) -> List[Dict[str, Any]]:
        """Load events without probe utilities (basic fallback).

        Args:
            run_path: Path to run directory
            event_type: Type of events to load

        Returns:
            List of event dicts
        """
        events: List[Dict[str, Any]] = []

        # Try loading uncompressed JSON files as fallback
        json_files = {
            "http": "deps_http.json",
            "sql": "deps_sql.json",
            "redis": "deps_redis.json",
            "events": "events.json",
        }

        if event_type == "all":
            for filename in json_files.values():
                file_path = run_path / filename
                if file_path.exists():
                    try:
                        with open(file_path) as f:
                            for line in f:
                                if line.strip():
                                    events.append(json.loads(line))
                    except Exception as e:
                        logger.warning("Failed to load %s: %s", filename, e)
        else:
            filename = json_files.get(event_type)
            if filename:
                file_path = run_path / filename
                if file_path.exists():
                    try:
                        with open(file_path) as f:
                            for line in f:
                                if line.strip():
                                    events.append(json.loads(line))
                    except Exception as e:
                        logger.warning("Failed to load %s: %s", filename, e)

        events.sort(key=lambda e: e.get("seq", 0))
        return events


# =============================================================================
# Utility Functions
# =============================================================================

def ground_failure_context(
    failure_context: FailureContext,
    run_dir: Optional[Union[str, Path]] = None,
    passing_run_dir: Optional[Union[str, Path]] = None,
) -> FailureContext:
    """Convenience function to ground a FailureContext with execution data.

    This updates the failure context in-place with grounded evidence,
    including divergence point and transcript summary.

    Args:
        failure_context: FailureContext to ground
        run_dir: Directory containing failing run data
        passing_run_dir: Directory containing passing run data (optional)

    Returns:
        Updated FailureContext with grounded data
    """
    grounder = ExecutionGrounder()

    failing_events = None
    passing_events = None

    # Load events if run directories provided
    if run_dir:
        try:
            failing_events = grounder.load_events_from_run_dir(run_dir)
        except Exception as e:
            logger.warning("Failed to load failing run events: %s", e)

    if passing_run_dir:
        try:
            passing_events = grounder.load_events_from_run_dir(passing_run_dir)
        except Exception as e:
            logger.warning("Failed to load passing run events: %s", e)

    # Ground with traces
    evidence = grounder.ground_with_traces(
        failure_context=failure_context,
        passing_run_events=passing_events,
        failing_run_events=failing_events,
    )

    # Update failure context
    if evidence.divergence:
        failure_context.divergence_point = {
            "index": evidence.divergence.index,
            "type": evidence.divergence.divergence_type.value,
            "description": evidence.divergence.description,
        }

    if evidence.io_summary:
        failure_context.transcript_summary = evidence.io_summary

    if evidence.relevant_locals:
        failure_context.locals_snapshot = evidence.relevant_locals

    return failure_context
