"""Prompt evolution system for netanel-core.

Rewrites prompts based on accumulated learnings. A batch operation — not every
call, only when triggers fire. One gpt-4o-mini call (~$0.001) rewrites the
prompt using accumulated patterns.

Architecture ref: ARCHITECTURE.md Section 12 (Prompt Evolution — Trigger System),
Section 13 Step 8.

Design decisions:
- Graceful degradation: evolve() returns None on failure, never crashes
- Pattern limit: top 20 patterns per evolution (JPL bounded input)
- Version metadata: JSON audit trail per version
- Rollback: dual-condition (post_avg < pre_avg AND post_avg < threshold)
- prompt_current.md is a COPY, not symlink (cross-system reliability)
"""

from __future__ import annotations

import contextlib
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from itertools import zip_longest
from pathlib import Path
from typing import Any, Protocol

from netanel_core.config import NathanConfig
from netanel_core.memory_store import Memory, MemoryStore

logger = logging.getLogger(__name__)

# ────────────────────────────────────────────
# Constants
# ────────────────────────────────────────────

MAX_PATTERNS_PER_EVOLUTION: int = 20
"""Max patterns fed to evolution LLM call. JPL Rule — bounded input."""

MAX_PROMPT_LENGTH_RATIO: float = 2.0
"""If evolved prompt is >2x original length, abort (not just warn) — BUG-016 fix."""

MIN_ABSOLUTE_PROMPT_GROWTH: int = 1000
"""Minimum absolute characters always allowed beyond the original length (BUG-066).
Short prompts (<500 chars) would be blocked by 2x ratio almost immediately since
LLMs add structure and examples. This floor ensures they can grow meaningfully."""

MIN_EVOLVED_PROMPT_LENGTH: int = 50
"""Minimum length for a valid evolved prompt. Catches empty/garbage LLM output."""

PREAMBLE_PATTERNS: tuple[str, ...] = (
    "here's the improved prompt:",
    "here is the revised prompt:",
    "here is the improved prompt:",
    "updated prompt:",
    "improved prompt:",
    "revised prompt:",
    "new prompt:",
)
"""Common LLM preamble patterns to strip before validating evolved prompts."""

DEFAULT_PROMPT_TEMPLATE: str = """\
You are a helpful AI assistant.

Your task is to complete the request accurately and thoroughly.

Follow these guidelines:
- Read the entire request carefully before responding
- Ask clarifying questions if the request is ambiguous
- Provide clear, structured responses
- Cite sources when making factual claims
- Admit when you don't know something rather than guessing

Complete the task to the best of your ability."""
"""Default prompt template used when auto-initializing a new namespace."""

# ────────────────────────────────────────────
# Data Types
# ────────────────────────────────────────────


@dataclass
class EvolutionResult:
    """Result of a successful prompt evolution.

    Returned by PromptEvolver.evolve() when evolution completes.
    """

    version: int
    """New prompt version number."""

    trigger: str
    """Which trigger fired (call_count, failure_rate, pattern_accumulation, manual)."""

    patterns_used: int
    """How many patterns were fed to the evolution LLM call."""

    previous_version: int
    """Version before evolution."""

    prompt_path: Path
    """Path to the new versioned prompt file."""

    metadata: dict = field(default_factory=dict)
    """Extra info."""


@dataclass
class RollbackResult:
    """Result of a prompt rollback.

    Returned by PromptEvolver.rollback() when rollback completes.
    """

    rolled_back: bool
    """Whether rollback actually happened."""

    from_version: int
    """Version we rolled back from."""

    to_version: int
    """Version we rolled back to."""

    stats: dict[str, float] = field(default_factory=dict)
    """Rollback stats: pre_avg, post_avg, scored_pre, scored_post."""


# ────────────────────────────────────────────
# Protocol
# ────────────────────────────────────────────


class EvolverModel(Protocol):
    """Protocol for the evolution LLM model — enables dependency injection.

    Matches EvalModel/LLMModel pattern from Evaluator and LearningLLM.
    Any object with an invoke() method that accepts messages and returns
    a string response works.
    """

    def invoke(self, messages: list[Any]) -> str: ...


# ────────────────────────────────────────────
# Evolution Prompt
# ────────────────────────────────────────────

_EVOLUTION_SYSTEM_PROMPT = """\
You are an expert prompt engineer specializing in self-improvement.

Your task: Rewrite the provided prompt to improve its effectiveness based on \
observed patterns.

## Rewriting Guidelines

1. **Preserve Core Intent**
   - Keep the original task/role definition intact
   - Don't change the fundamental goal

2. **Address Observed Patterns**
   - If patterns show repeated corrections → add explicit instruction
   - If patterns show something is forgotten → strengthen that instruction
   - If patterns show something works well → keep those instructions unchanged

3. **Structural Improvements**
   - Add missing constraints if patterns reveal edge cases
   - Break complex instructions into numbered steps
   - Add output format specifications if patterns show inconsistency

4. **Avoid Over-Engineering**
   - Don't add instructions that aren't justified by patterns
   - Don't make the prompt more than 2x longer unless patterns strongly justify it
   - Keep language clear and direct

5. **Meta-Instruction Awareness**
   - If patterns show the assistant ignores certain instructions, rephrase them \
more forcefully
   - If patterns show confusion about priorities, add explicit priority rankings

START DIRECTLY with the rewritten prompt. No preamble, no explanation. \
The output will be used directly as the new system prompt."""


def _build_evolution_prompt(
    current_prompt: str,
    patterns: list[str],
    trigger: str,
    tool_history: str | None = None,
) -> str:
    """Build the human message for prompt evolution.

    Args:
        current_prompt: The current system prompt to rewrite.
        patterns: List of observed patterns (max 20 will be used).
        trigger: Why evolution was triggered.
        tool_history: Optional tool usage stats (None until Step 9).

    Returns:
        Formatted prompt string for the evolution model.
    """
    limited_patterns = patterns[:MAX_PATTERNS_PER_EVOLUTION]
    patterns_section = "\n".join(f"- {p}" for p in limited_patterns)

    parts = [
        f"## CURRENT PROMPT\n{current_prompt}",
        f"\n## OBSERVED PATTERNS ({len(patterns)} total, "
        f"showing top {len(limited_patterns)})\n{patterns_section}",
        f"\n## TRIGGER REASON\n{trigger}",
    ]

    if tool_history:
        parts.append(f"\n## TOOL USAGE STATS\n{tool_history}")

    parts.append(
        "\n## TASK\n"
        "Rewrite the prompt to address the observed patterns while "
        "preserving core functionality."
    )

    return "\n".join(parts)


def _clean_and_validate_prompt(new_prompt: str, current_prompt: str) -> str | None:
    """Clean and validate an evolved prompt before saving.

    BUG-016 fix: LLM output is saved directly without validation, silently
    corrupting system prompts with code fences, preamble text, or empty output.

    Pipeline:
    1. Strip outer code fence (handles ` ```text `, ` ```markdown `, etc.)
    2. Strip whitespace
    3. Strip common LLM preamble patterns (first line only)
    4. Abort if stripped length < MIN_EVOLVED_PROMPT_LENGTH
    5. Defensive abort if current_prompt is empty (can't validate ratio)
    6. Abort if length explosion (>MAX_PROMPT_LENGTH_RATIO x original) — not just warn

    Args:
        new_prompt: The raw LLM output to clean and validate.
        current_prompt: The existing system prompt (used for length ratio check).

    Returns:
        Cleaned prompt string, or None if validation fails (abort evolution).
    """
    # Step 1: Strip outer whitespace so ^``` regex can anchor at the true start.
    # LLM output with a leading newline/space would otherwise fail the match.
    cleaned = new_prompt.strip()

    # Step 2: Strip outer code fence if present.
    # Language pattern [a-zA-Z0-9_-]+ covers python, python3, shell-script, etc.
    # \n? before closing ``` handles content without a trailing newline.
    # DOTALL flag allows (.*?) to match multi-line content inside the fence.
    cleaned = re.sub(
        r"^```(?:[a-zA-Z0-9_-]+)?\s*\n(.*?)\n?```\s*$", r"\1", cleaned, flags=re.DOTALL
    )

    # Step 3: Strip whitespace again after potential fence removal
    cleaned = cleaned.strip()

    # Step 4: Strip common preamble patterns.
    # Strip only the matched prefix, keeping any inline content after the colon.
    # e.g. "Here's the improved prompt: You are..." → "You are..."
    # Guard: splitlines() returns [] on empty string — skip preamble check if empty.
    lines = cleaned.splitlines()
    if lines:
        first_line, *rest = lines
        first_line_lower = first_line.lower()
        for pattern in PREAMBLE_PATTERNS:
            if first_line_lower.startswith(pattern):
                remainder = first_line[len(pattern) :].strip()
                cleaned = "\n".join([remainder, *rest] if remainder else rest).strip()
                break

    # Step 5: Minimum length check (use stripped length — catches whitespace-only)
    if len(cleaned.strip()) < MIN_EVOLVED_PROMPT_LENGTH:
        logger.warning(
            "Evolution aborted: evolved prompt too short (%d chars < %d min). "
            "LLM may have returned empty or garbage output.",
            len(cleaned.strip()),
            MIN_EVOLVED_PROMPT_LENGTH,
        )
        return None

    # Step 6: Defensive empty baseline check (division guard)
    if not current_prompt:
        logger.warning("Evolution aborted: current_prompt is empty — cannot validate ratio.")
        return None

    # Step 7: Length explosion check — abort (not just warn).
    # BUG-066: For short prompts, 2x ratio is too aggressive — LLMs naturally add
    # structure and examples. Allow at least MIN_ABSOLUTE_PROMPT_GROWTH chars beyond
    # original so short prompts can evolve meaningfully.
    max_allowed_len = max(
        int(len(current_prompt) * MAX_PROMPT_LENGTH_RATIO),
        len(current_prompt) + MIN_ABSOLUTE_PROMPT_GROWTH,
    )
    if len(cleaned) > max_allowed_len:
        logger.warning(
            "Evolution aborted: prompt explosion detected (%.1fx growth, %d → %d chars, "
            "max allowed %d). Keeping original prompt.",
            len(cleaned) / len(current_prompt),
            len(current_prompt),
            len(cleaned),
            max_allowed_len,
        )
        return None

    return cleaned


def _interleave(
    globals_: list[Memory],
    namespace: list[Memory],
    cap: int = MAX_PATTERNS_PER_EVOLUTION,
) -> list[Memory]:
    """Interleave global and namespace memories in round-robin order (BUG-027 fix).

    Duplicated from context_builder.py following module independence pattern —
    prompt_evolver must not import from context_builder.

    Uses zip_longest so both lists are fully consumed regardless of length.
    This ensures globals survive proportionally under the cap limit — not all
    dropped at once when namespace memories outnumber globals.

    The ``cap`` parameter enforces the JPL safety bound (no unbounded iteration).
    Stops as soon as ``len(result) >= cap``.

    Example: [G1, G2], [N1, N2, N3] → [G1, N1, G2, N2, N3]
    """
    result: list[Memory] = []
    for pair in zip_longest(globals_, namespace):
        result.extend(m for m in pair if m is not None)
        if len(result) >= cap:
            break
    return result[:cap]


# ────────────────────────────────────────────
# PromptEvolver
# ────────────────────────────────────────────


class PromptEvolver:
    """Prompt evolution system — rewrites prompts from accumulated learnings.

    A batch operation that reads the current prompt, reads accumulated patterns,
    makes ONE cheap LLM call to rewrite the prompt, saves it as a new version,
    and keeps the old version for rollback.

    Usage:
        evolver = PromptEvolver(config)
        result = evolver.evolve(trigger="pattern_accumulation")
        if result:
            print(f"Evolved to v{result.version}")

    All methods follow graceful degradation — failures are logged, never propagated.
    """

    def __init__(
        self,
        config: NathanConfig,
        store: MemoryStore | None = None,
        model: EvolverModel | None = None,
    ) -> None:
        """Initialize the prompt evolution system.

        Args:
            config: Nathan configuration (single source of truth).
            store: Override MemoryStore (for testing). If None, creates one.
            model: Override evolution model (for testing). If None, creates
                ChatOpenAI with config.models.extractor_model (gpt-4o-mini).
        """
        self._config = config
        self._store = store or MemoryStore(config)
        # BUG-047: cache to avoid repeated directory scans
        self._version_cache: int | None = None

        if model is not None:
            self._model = model
        else:
            from langchain_openai import ChatOpenAI

            self._model = ChatOpenAI(  # type: ignore[assignment,call-arg]
                model=config.models.extractor_model,
                temperature=0,
                max_completion_tokens=config.evolution.max_evolution_tokens,
                max_retries=config.safety.max_retries,
                timeout=config.safety.model_timeout_seconds,
            )

    # ────────────────────────────────────────
    # Public API
    # ────────────────────────────────────────

    def evolve(self, trigger: str = "manual") -> EvolutionResult | None:
        """Execute prompt evolution.

        Reads current prompt and accumulated patterns, makes one LLM call
        to rewrite the prompt, saves as a new version with metadata.

        Args:
            trigger: Why evolution was triggered. One of:
                call_count, failure_rate, pattern_accumulation, manual.

        Returns:
            EvolutionResult on success, None on failure or if no prompt exists.
        """
        try:
            return self._evolve_impl(trigger)
        except Exception:
            logger.warning(
                "Prompt evolution failed (trigger=%s). Graceful degradation.",
                trigger,
                exc_info=True,
            )
            return None

    def check_rollback(
        self,
        pre_scores: list[float | None],
        post_scores: list[float | None],
    ) -> tuple[bool, dict[str, float]]:
        """Check if rollback should be triggered.

        Compares average scores before and after evolution.
        Dual condition: post_avg < pre_avg AND post_avg < rollback_threshold.
        None scores are filtered out. Requires at least 2 scored calls per window.

        Args:
            pre_scores: Quality scores from calls before evolution.
            post_scores: Quality scores from calls after evolution.

        Returns:
            Tuple of (should_rollback, stats_dict).
            stats_dict contains: pre_avg, post_avg, scored_pre, scored_post.
        """
        # Filter None values
        scored_pre = [s for s in pre_scores if s is not None]
        scored_post = [s for s in post_scores if s is not None]

        stats: dict[str, float] = {
            "pre_avg": 0.0,
            "post_avg": 0.0,
            "scored_pre": float(len(scored_pre)),
            "scored_post": float(len(scored_post)),
        }

        # Insufficient data — need at least 2 scored calls per window
        if len(scored_pre) < 2 or len(scored_post) < 2:
            return False, stats

        pre_avg = sum(scored_pre) / len(scored_pre)
        post_avg = sum(scored_post) / len(scored_post)

        stats["pre_avg"] = pre_avg
        stats["post_avg"] = post_avg

        # Dual condition: quality decreased AND below minimum threshold
        threshold = self._config.evolution.rollback_threshold
        should_rollback = post_avg < pre_avg and post_avg < threshold

        return should_rollback, stats

    def rollback(self) -> RollbackResult | None:
        """Rollback to the previous prompt version.

        Restores the previous version as prompt_current.md and marks the
        current version's metadata as rolled_back.

        Returns:
            RollbackResult on success, None if rollback is not possible.
        """
        try:
            return self._rollback_impl()
        except Exception:
            logger.warning(
                "Prompt rollback failed. Graceful degradation.",
                exc_info=True,
            )
            return None

    # ────────────────────────────────────────
    # Internal: Evolution Implementation
    # ────────────────────────────────────────

    def _evolve_impl(self, trigger: str) -> EvolutionResult | None:
        """Internal evolution implementation.

        Steps:
        1. Read current prompt (auto-initialize if missing)
        2. Read patterns
        3. Read tool history (optional)
        4. Build evolution prompt
        5. Call LLM
        6. Get next version number
        7. Write versioned prompt
        8. Copy to prompt_current.md
        9. Write metadata
        10. Return result
        """
        # Step 1: Read current prompt (auto-initialize if missing)
        current_prompt = self._store.read_prompt()
        if current_prompt is None:
            logger.info(
                "No current prompt found for namespace '%s'. "
                "Auto-initializing with default template.",
                self._config.namespace,
            )
            # Auto-initialize with default prompt
            self._store.write_prompt(DEFAULT_PROMPT_TEMPLATE)
            current_prompt = DEFAULT_PROMPT_TEMPLATE

        # Step 2: Read patterns — interleave global and namespace memories (BUG-027 fix).
        # Global memories = shared learnings applicable to ALL roles; must inform evolution
        # with the same weight as namespace memories, following the ContextBuilder pattern.
        global_memories = self._store.read_global_memories()
        namespace_memories = self._store.read_all_memories()
        memories = _interleave(global_memories, namespace_memories, cap=MAX_PATTERNS_PER_EVOLUTION)

        # BUG-084: Guard — skip LLM call if there is nothing to learn from.
        # Firing an API call with zero patterns wastes budget and produces a
        # meaningless evolution (patterns_used=0). Return None so callers
        # (which already handle None) simply skip this evolution cycle.
        if not memories:
            logger.info("evolve() skipped: no patterns yet (trigger=%s)", trigger)
            return None

        # BUG-028 fix: filter to pattern-category memories only, so decisions
        # and improvements are not mislabeled as patterns in the evolution LLM
        # prompt. Falls back to all memories when no pattern-category entries
        # exist (backward compatibility).
        pattern_memories = [m.content for m in memories if m.metadata.get("category") == "pattern"]
        patterns = pattern_memories if pattern_memories else [m.content for m in memories]

        # Step 3: Read tool history (optional — skipped until Step 9)
        tool_history = self._read_tool_history()

        # Step 4: Build evolution prompt
        human_message = _build_evolution_prompt(current_prompt, patterns, trigger, tool_history)

        # Step 5: Call LLM
        from langchain_core.messages import HumanMessage, SystemMessage

        messages = [
            SystemMessage(content=_EVOLUTION_SYSTEM_PROMPT),
            HumanMessage(content=human_message),
        ]
        result = self._model.invoke(messages)

        # Extract string from AIMessage if needed
        raw_prompt = str(result.content) if hasattr(result, "content") else str(result)

        # Validate and clean LLM output (BUG-016: strips code fences, preamble;
        # aborts on empty/short/exploded prompts — not just logs a warning)
        new_prompt = _clean_and_validate_prompt(raw_prompt, current_prompt)
        if new_prompt is None:
            return None  # Abort evolution — current prompt is preserved

        # Step 6: Get next version number
        previous_version = self._get_current_version()
        next_version = previous_version + 1

        # Step 7: Write versioned prompt
        prompt_path = self._store.write_prompt(new_prompt, version=f"v{next_version}")

        # Step 8: Copy to prompt_current.md
        self._store.write_prompt(new_prompt)

        # Step 9: Write metadata
        self._write_metadata(next_version, trigger, len(patterns[:MAX_PATTERNS_PER_EVOLUTION]))

        # BUG-047: update cache to the newly written version (no re-scan needed)
        self._version_cache = next_version

        logger.info(
            "Prompt evolved: v%d → v%d (trigger=%s, patterns=%d)",
            previous_version,
            next_version,
            trigger,
            len(patterns[:MAX_PATTERNS_PER_EVOLUTION]),
        )

        # Step 10: Return result
        return EvolutionResult(
            version=next_version,
            trigger=trigger,
            patterns_used=len(patterns[:MAX_PATTERNS_PER_EVOLUTION]),
            previous_version=previous_version,
            prompt_path=prompt_path,
        )

    # ────────────────────────────────────────
    # Internal: Rollback Implementation
    # ────────────────────────────────────────

    def _rollback_impl(self) -> RollbackResult | None:
        """Internal rollback implementation."""
        current_version = self._get_current_version()

        # Can't rollback from v1 or v0
        if current_version <= 1:
            logger.warning(
                "Cannot rollback from v%d — no previous version exists.",
                current_version,
            )
            return None

        previous_version = current_version - 1

        # Read previous version
        previous_prompt = self._store.read_prompt(version=f"v{previous_version}")
        if previous_prompt is None:
            logger.error(
                "Rollback failed: prompt_v%d.md not found. File may have been manually deleted.",
                previous_version,
            )
            return None

        # Restore as current
        self._store.write_prompt(previous_prompt)

        # BUG-047: invalidate cache — the highest numbered version file still exists
        # (rolled-back files are not deleted), so we must re-scan to get the true max.
        self._version_cache = None

        # Mark failed version metadata
        self._update_metadata(current_version, {"rolled_back": True})

        logger.info(
            "Prompt rolled back: v%d → v%d",
            current_version,
            previous_version,
        )

        return RollbackResult(
            rolled_back=True,
            from_version=current_version,
            to_version=previous_version,
        )

    # ────────────────────────────────────────
    # Internal: Version Management
    # ────────────────────────────────────────

    def _get_current_version(self) -> int:
        """Return the highest prompt version number found in prompts_dir (BUG-047: cached).

        Scans on first call; subsequent calls return the cached value.
        Cache is invalidated by _evolve_impl (new version written) and
        _rollback_impl (version reverted).

        Returns:
            Highest version number found, or 0 if no versions exist.
        """
        if self._version_cache is not None:
            return self._version_cache

        prompts_dir = self._config.prompts_dir
        if not prompts_dir.exists():
            self._version_cache = 0
            return 0

        version_pattern = re.compile(r"^prompt_v(\d+)\.md$")
        versions: list[int] = []

        for filepath in prompts_dir.iterdir():
            match = version_pattern.match(filepath.name)
            if match:
                versions.append(int(match.group(1)))

        self._version_cache = max(versions, default=0)
        return self._version_cache

    def _write_metadata(self, version: int, trigger: str, patterns_used: int) -> Path:
        """Write version metadata JSON file.

        Args:
            version: The prompt version number.
            trigger: Which trigger fired.
            patterns_used: Number of patterns fed to evolution.

        Returns:
            Path to the metadata file.
        """
        prompts_dir = self._config.prompts_dir
        prompts_dir.mkdir(parents=True, exist_ok=True)

        metadata = {
            "version": version,
            "created_at": datetime.now(tz=UTC).isoformat(),
            "trigger": trigger,
            "patterns_used": patterns_used,
            "rolled_back": False,
        }

        filepath = prompts_dir / f"prompt_v{version}_meta.json"
        filepath.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
        return filepath

    def _read_metadata(self, version: int) -> dict | None:
        """Read version metadata from JSON file.

        Args:
            version: The prompt version number.

        Returns:
            Parsed metadata dict, or None if missing/corrupt.
        """
        filepath = self._config.prompts_dir / f"prompt_v{version}_meta.json"
        if not filepath.exists():
            return None

        try:
            raw = json.loads(filepath.read_text(encoding="utf-8"))
            if not isinstance(raw, dict):
                logger.warning(
                    "Metadata format invalid for v%d — expected object.",
                    version,
                )
                return None
            # Validate version field matches filename
            if raw.get("version") != version:
                logger.warning(
                    "Metadata version mismatch: file says v%s, expected v%d. Correcting.",
                    raw.get("version"),
                    version,
                )
                raw["version"] = version
            return raw
        except (json.JSONDecodeError, OSError):
            logger.warning(
                "Failed to read metadata for v%d. File may be corrupt.",
                version,
                exc_info=True,
            )
            return None

    def _update_metadata(self, version: int, updates: dict) -> None:
        """Update existing version metadata.

        Reads current metadata, merges updates, writes back.

        Args:
            version: The prompt version number.
            updates: Key-value pairs to merge into metadata.
        """
        metadata = self._read_metadata(version)
        if metadata is None:
            logger.warning(
                "Cannot update metadata for v%d — file not found.",
                version,
            )
            return

        metadata.update(updates)
        filepath = self._config.prompts_dir / f"prompt_v{version}_meta.json"
        with contextlib.suppress(OSError):
            filepath.write_text(json.dumps(metadata, indent=2), encoding="utf-8")

    def _read_tool_history(self) -> str | None:
        """Summarize tool history for evolution prompt (not raw JSONL).

        BUG-029: Raw JSONL = token waste (~50k tokens for 1000 entries).
        Summarize to "tool: N calls, X% success, avg Yms" — one line per tool.

        Tool history is created by Step 9 (ToolSelector). Until then,
        this returns None — gracefully skipped.

        Returns:
            Per-tool summary string, or None if not available/empty.
        """
        tool_history_path = self._config.namespace_dir / "tool_history.jsonl"
        if not tool_history_path.exists():
            return None

        tool_stats: dict[str, dict[str, int]] = {}
        try:
            lines = tool_history_path.read_text(encoding="utf-8").splitlines()
            for line in lines:
                line = line.strip()
                if not line:
                    continue
                with contextlib.suppress(json.JSONDecodeError):
                    data = json.loads(line)
                    tool = str(data.get("tool", "unknown"))
                    if tool not in tool_stats:
                        tool_stats[tool] = {"total": 0, "success": 0, "total_ms": 0}
                    tool_stats[tool]["total"] += 1
                    if data.get("success"):
                        tool_stats[tool]["success"] += 1
                    tool_stats[tool]["total_ms"] += int(data.get("duration_ms", 0))
        except OSError:
            return None

        if not tool_stats:
            return None

        summary_lines = []
        for tool, stats in sorted(tool_stats.items()):
            total = stats["total"]
            rate = stats["success"] / total if total > 0 else 0.0
            avg_ms = stats["total_ms"] / total if total > 0 else 0.0
            summary_lines.append(f"- {tool}: {total} calls, {rate:.0%} success, avg {avg_ms:.0f}ms")
        return "\n".join(summary_lines)
