"""Feedback Loop System for LLM-First Debugging.

This module implements a production-ready feedback collection and learning system
that improves debugging predictions over time by:
- Recording fix outcomes and hypothesis accuracy
- Tracking agent performance across debugging sessions
- Learning optimal embedding weights from feedback
- Adjusting agent weights based on historical accuracy
- Updating strategy priorities based on success rates

The system uses exponential moving averages for smooth weight updates
and implements proper data structures for efficient storage and retrieval.
"""

from __future__ import annotations

import json
import logging
import statistics
import threading
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


# =============================================================================
# Enums
# =============================================================================


class FixOutcome(str, Enum):
    """Outcome of a fix attempt.

    ACCEPTED: User accepted the fix without modifications
    REJECTED: User rejected the fix entirely
    MODIFIED: User accepted but made modifications
    FAILED_VERIFICATION: Fix passed tests but failed determinism verification
    """

    ACCEPTED = "accepted"
    REJECTED = "rejected"
    MODIFIED = "modified"
    FAILED_VERIFICATION = "failed_verification"


# =============================================================================
# Dataclasses
# =============================================================================


@dataclass(frozen=True)
class EmbeddingWeights:
    """Weights for combining different embedding components.

    These weights determine how much each embedding type contributes
    to the final combined embedding used for similarity search.
    """

    error_embedding_weight: float = 0.4
    code_before_weight: float = 0.2
    code_after_weight: float = 0.2
    description_weight: float = 0.2

    # Category-specific adjustments
    category_weights: Dict[str, float] = field(default_factory=dict)

    # Version for cache invalidation
    version: int = 1

    def __post_init__(self) -> None:
        """Validate weights sum to approximately 1.0."""
        components = (
            self.error_embedding_weight,
            self.code_before_weight,
            self.code_after_weight,
            self.description_weight,
        )
        if any((w < 0.0 or w > 1.0) for w in components):
            raise ValueError("Embedding component weights must each be in [0.0, 1.0]")
        total = (
            self.error_embedding_weight
            + self.code_before_weight
            + self.code_after_weight
            + self.description_weight
        )
        if not (0.99 <= total <= 1.01):
            raise ValueError(f"Weights must sum to 1.0, got {total}")

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "error_embedding_weight": self.error_embedding_weight,
            "code_before_weight": self.code_before_weight,
            "code_after_weight": self.code_after_weight,
            "description_weight": self.description_weight,
            "category_weights": dict(self.category_weights),
            "version": self.version,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> EmbeddingWeights:
        """Create from dictionary."""
        return cls(
            error_embedding_weight=data.get("error_embedding_weight", 0.4),
            code_before_weight=data.get("code_before_weight", 0.2),
            code_after_weight=data.get("code_after_weight", 0.2),
            description_weight=data.get("description_weight", 0.2),
            category_weights=data.get("category_weights", {}),
            version=data.get("version", 1),
        )


@dataclass(frozen=True)
class AgentWeights:
    """Weights for specialist agents in the multi-agent debate.

    Higher weights mean the agent's hypotheses are given more
    consideration in the final consensus.
    """

    # Core agent weights (agent_name -> weight)
    weights: Dict[str, float] = field(default_factory=dict)

    # Performance metrics for transparency
    accuracy_scores: Dict[str, float] = field(default_factory=dict)
    sample_counts: Dict[str, int] = field(default_factory=dict)

    # Minimum samples required before adjusting weights
    min_samples_for_adjustment: int = 10

    # Version for cache invalidation
    version: int = 1

    def get_weight(self, agent_name: str, default: float = 1.0) -> float:
        """Get weight for an agent with default fallback."""
        return self.weights.get(agent_name, default)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "weights": dict(self.weights),
            "accuracy_scores": dict(self.accuracy_scores),
            "sample_counts": dict(self.sample_counts),
            "min_samples_for_adjustment": self.min_samples_for_adjustment,
            "version": self.version,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> AgentWeights:
        """Create from dictionary."""
        return cls(
            weights=data.get("weights", {}),
            accuracy_scores=data.get("accuracy_scores", {}),
            sample_counts=data.get("sample_counts", {}),
            min_samples_for_adjustment=data.get("min_samples_for_adjustment", 10),
            version=data.get("version", 1),
        )


@dataclass(frozen=True)
class StrategyPriorities:
    """Priority ordering and weights for fix generation strategies.

    Strategies with higher priorities are tried first and given
    more weight in ensemble generation.
    """

    # Strategy priorities (strategy -> priority score)
    priorities: Dict[str, float] = field(default_factory=dict)

    # Success rates for transparency
    success_rates: Dict[str, float] = field(default_factory=dict)
    attempt_counts: Dict[str, int] = field(default_factory=dict)

    # Category-specific priority overrides
    category_priorities: Dict[str, Dict[str, float]] = field(default_factory=dict)

    # Version for cache invalidation
    version: int = 1

    def get_priority(self, strategy: str, category: Optional[str] = None) -> float:
        """Get priority for a strategy, optionally category-specific."""
        if category and category in self.category_priorities:
            cat_priorities = self.category_priorities[category]
            if strategy in cat_priorities:
                return cat_priorities[strategy]
        return self.priorities.get(strategy, 0.5)

    def get_ordered_strategies(
        self, category: Optional[str] = None
    ) -> List[Tuple[str, float]]:
        """Get strategies ordered by priority (highest first)."""
        if category and category in self.category_priorities:
            priorities = {
                **self.priorities,
                **self.category_priorities[category],
            }
        else:
            priorities = self.priorities

        return sorted(priorities.items(), key=lambda x: x[1], reverse=True)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "priorities": dict(self.priorities),
            "success_rates": dict(self.success_rates),
            "attempt_counts": dict(self.attempt_counts),
            "category_priorities": {
                k: dict(v) for k, v in self.category_priorities.items()
            },
            "version": self.version,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> StrategyPriorities:
        """Create from dictionary."""
        return cls(
            priorities=data.get("priorities", {}),
            success_rates=data.get("success_rates", {}),
            attempt_counts=data.get("attempt_counts", {}),
            category_priorities=data.get("category_priorities", {}),
            version=data.get("version", 1),
        )


# =============================================================================
# Feedback Records
# =============================================================================


@dataclass
class FixFeedbackRecord:
    """Record of feedback for a fix attempt."""

    fix_id: str
    outcome: FixOutcome
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    # Context for learning
    error_type: Optional[str] = None
    category: Optional[str] = None
    strategy: Optional[str] = None

    # Similarity search metadata
    similarity_score: Optional[float] = None
    embedding_version: Optional[int] = None

    # User feedback
    user_comment: Optional[str] = None
    modification_diff: Optional[str] = None

    # Verification results
    verification_runs: int = 0
    verification_passes: int = 0


@dataclass
class HypothesisFeedbackRecord:
    """Record of feedback for a hypothesis accuracy."""

    hypothesis_id: str
    was_correct: bool
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    # Context
    agent_name: Optional[str] = None
    category: Optional[str] = None
    confidence: Optional[float] = None

    # Evidence used
    evidence_count: int = 0
    debate_rounds: int = 0


@dataclass
class AgentPerformanceRecord:
    """Record of agent performance on a hypothesis."""

    agent_name: str
    hypothesis_id: str
    accuracy: float  # 0.0 to 1.0
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    # Context
    category: Optional[str] = None
    was_winner: bool = False
    confidence: Optional[float] = None


# =============================================================================
# Feedback Collector
# =============================================================================


class FeedbackCollector:
    """Collects and stores feedback for fixes, hypotheses, and agents.

    Thread-safe implementation that maintains bounded history
    and provides efficient retrieval for learning.

    Example:
        >>> collector = FeedbackCollector()
        >>> collector.record_fix_outcome("fix_123", FixOutcome.ACCEPTED)
        >>> collector.record_hypothesis_accuracy("hyp_456", was_correct=True)
        >>> collector.record_agent_performance("timing_agent", "hyp_456", accuracy=0.85)
    """

    def __init__(
        self,
        max_records: int = 50000,
        retention_days: int = 90,
    ):
        """Initialize the feedback collector.

        Args:
            max_records: Maximum records to keep in memory per type.
            retention_days: Days to retain records before cleanup.
        """
        self._max_records = max_records
        self._retention_days = retention_days
        self._lock = threading.RLock()

        # Storage
        self._fix_records: List[FixFeedbackRecord] = []
        self._hypothesis_records: List[HypothesisFeedbackRecord] = []
        self._agent_records: List[AgentPerformanceRecord] = []

        # Indexes for efficient lookup
        self._fix_by_id: Dict[str, FixFeedbackRecord] = {}
        self._hypothesis_by_id: Dict[str, HypothesisFeedbackRecord] = {}
        self._agent_by_name: Dict[str, List[AgentPerformanceRecord]] = defaultdict(list)

        # Callbacks for real-time learning
        self._on_fix_outcome: List[Callable[[FixFeedbackRecord], None]] = []
        self._on_hypothesis_accuracy: List[
            Callable[[HypothesisFeedbackRecord], None]
        ] = []
        self._on_agent_performance: List[Callable[[AgentPerformanceRecord], None]] = []

        logger.debug(
            "FeedbackCollector initialized with max_records=%d, retention_days=%d",
            max_records,
            retention_days,
        )

    def record_fix_outcome(
        self,
        fix_id: str,
        outcome: FixOutcome,
        *,
        error_type: Optional[str] = None,
        category: Optional[str] = None,
        strategy: Optional[str] = None,
        similarity_score: Optional[float] = None,
        embedding_version: Optional[int] = None,
        user_comment: Optional[str] = None,
        modification_diff: Optional[str] = None,
        verification_runs: int = 0,
        verification_passes: int = 0,
    ) -> FixFeedbackRecord:
        """Record the outcome of a fix attempt.

        Args:
            fix_id: Unique identifier for the fix.
            outcome: The outcome of the fix attempt.
            error_type: Type of error being fixed.
            category: Flakiness category (timing, ordering, etc.).
            strategy: Strategy used to generate the fix.
            similarity_score: Similarity score from RAG search.
            embedding_version: Version of embeddings used.
            user_comment: Optional user feedback comment.
            modification_diff: Diff if user modified the fix.
            verification_runs: Number of verification runs.
            verification_passes: Number of successful verifications.

        Returns:
            The created feedback record.
        """
        record = FixFeedbackRecord(
            fix_id=fix_id,
            outcome=outcome,
            error_type=error_type,
            category=category,
            strategy=strategy,
            similarity_score=similarity_score,
            embedding_version=embedding_version,
            user_comment=user_comment,
            modification_diff=modification_diff,
            verification_runs=verification_runs,
            verification_passes=verification_passes,
        )

        with self._lock:
            self._fix_records.append(record)
            self._fix_by_id[fix_id] = record
            self._maybe_trim_fix_records()
            callbacks = list(self._on_fix_outcome)

        logger.info(
            "Recorded fix outcome: fix_id=%s, outcome=%s, strategy=%s",
            fix_id,
            outcome.value,
            strategy,
        )

        # Trigger callbacks using a lock-protected callback snapshot.
        for callback in callbacks:
            try:
                callback(record)
            except Exception as e:
                logger.error("Fix outcome callback failed: %s", e)

        return record

    def record_hypothesis_accuracy(
        self,
        hypothesis_id: str,
        was_correct: bool,
        *,
        agent_name: Optional[str] = None,
        category: Optional[str] = None,
        confidence: Optional[float] = None,
        evidence_count: int = 0,
        debate_rounds: int = 0,
    ) -> HypothesisFeedbackRecord:
        """Record whether a hypothesis was correct.

        Args:
            hypothesis_id: Unique identifier for the hypothesis.
            was_correct: Whether the hypothesis was correct.
            agent_name: Name of the agent that proposed it.
            category: Flakiness category.
            confidence: Agent's confidence in the hypothesis.
            evidence_count: Number of evidence items used.
            debate_rounds: Number of debate rounds before consensus.

        Returns:
            The created feedback record.
        """
        record = HypothesisFeedbackRecord(
            hypothesis_id=hypothesis_id,
            was_correct=was_correct,
            agent_name=agent_name,
            category=category,
            confidence=confidence,
            evidence_count=evidence_count,
            debate_rounds=debate_rounds,
        )

        with self._lock:
            self._hypothesis_records.append(record)
            self._hypothesis_by_id[hypothesis_id] = record
            self._maybe_trim_hypothesis_records()
            callbacks = list(self._on_hypothesis_accuracy)

        logger.info(
            "Recorded hypothesis accuracy: hypothesis_id=%s, was_correct=%s, agent=%s",
            hypothesis_id,
            was_correct,
            agent_name,
        )

        # Trigger callbacks using a lock-protected callback snapshot.
        for callback in callbacks:
            try:
                callback(record)
            except Exception as e:
                logger.error("Hypothesis accuracy callback failed: %s", e)

        return record

    def record_agent_performance(
        self,
        agent: str,
        hypothesis_id: str,
        accuracy: float,
        *,
        category: Optional[str] = None,
        was_winner: bool = False,
        confidence: Optional[float] = None,
    ) -> AgentPerformanceRecord:
        """Record an agent's performance on a hypothesis.

        Args:
            agent: Name of the specialist agent.
            hypothesis_id: ID of the hypothesis evaluated.
            accuracy: Accuracy score from 0.0 to 1.0.
            category: Flakiness category.
            was_winner: Whether this agent's hypothesis won the debate.
            confidence: Agent's reported confidence.

        Returns:
            The created performance record.
        """
        if not 0.0 <= accuracy <= 1.0:
            raise ValueError(f"Accuracy must be between 0 and 1, got {accuracy}")

        record = AgentPerformanceRecord(
            agent_name=agent,
            hypothesis_id=hypothesis_id,
            accuracy=accuracy,
            category=category,
            was_winner=was_winner,
            confidence=confidence,
        )

        with self._lock:
            self._agent_records.append(record)
            self._agent_by_name[agent].append(record)
            self._maybe_trim_agent_records()
            callbacks = list(self._on_agent_performance)

        logger.info(
            "Recorded agent performance: agent=%s, accuracy=%.2f, was_winner=%s",
            agent,
            accuracy,
            was_winner,
        )

        # Trigger callbacks using a lock-protected callback snapshot.
        for callback in callbacks:
            try:
                callback(record)
            except Exception as e:
                logger.error("Agent performance callback failed: %s", e)

        return record

    def get_fix_outcomes(
        self,
        *,
        strategy: Optional[str] = None,
        category: Optional[str] = None,
        outcome: Optional[FixOutcome] = None,
        since: Optional[datetime] = None,
        limit: int = 1000,
    ) -> List[FixFeedbackRecord]:
        """Get fix outcome records with optional filtering.

        Args:
            strategy: Filter by strategy.
            category: Filter by category.
            outcome: Filter by outcome.
            since: Filter to records after this time.
            limit: Maximum records to return.

        Returns:
            List of matching fix feedback records.
        """
        with self._lock:
            records = list(self._fix_records)

        # Apply filters
        if strategy:
            records = [r for r in records if r.strategy == strategy]
        if category:
            records = [r for r in records if r.category == category]
        if outcome:
            records = [r for r in records if r.outcome == outcome]
        if since:
            records = [r for r in records if r.timestamp >= since]

        # Return most recent first
        return sorted(records, key=lambda r: r.timestamp, reverse=True)[:limit]

    def get_hypothesis_outcomes(
        self,
        *,
        agent_name: Optional[str] = None,
        category: Optional[str] = None,
        was_correct: Optional[bool] = None,
        since: Optional[datetime] = None,
        limit: int = 1000,
    ) -> List[HypothesisFeedbackRecord]:
        """Get hypothesis accuracy records with optional filtering.

        Args:
            agent_name: Filter by agent name.
            category: Filter by category.
            was_correct: Filter by correctness.
            since: Filter to records after this time.
            limit: Maximum records to return.

        Returns:
            List of matching hypothesis feedback records.
        """
        with self._lock:
            records = list(self._hypothesis_records)

        # Apply filters
        if agent_name:
            records = [r for r in records if r.agent_name == agent_name]
        if category:
            records = [r for r in records if r.category == category]
        if was_correct is not None:
            records = [r for r in records if r.was_correct == was_correct]
        if since:
            records = [r for r in records if r.timestamp >= since]

        return sorted(records, key=lambda r: r.timestamp, reverse=True)[:limit]

    def get_agent_performances(
        self,
        agent: str,
        *,
        since: Optional[datetime] = None,
        limit: int = 1000,
    ) -> List[AgentPerformanceRecord]:
        """Get performance records for a specific agent.

        Args:
            agent: Agent name.
            since: Filter to records after this time.
            limit: Maximum records to return.

        Returns:
            List of agent performance records.
        """
        with self._lock:
            records = list(self._agent_by_name.get(agent, []))

        if since:
            records = [r for r in records if r.timestamp >= since]

        return sorted(records, key=lambda r: r.timestamp, reverse=True)[:limit]

    def register_fix_outcome_callback(
        self, callback: Callable[[FixFeedbackRecord], None]
    ) -> None:
        """Register a callback for fix outcomes."""
        with self._lock:
            self._on_fix_outcome.append(callback)

    def register_hypothesis_accuracy_callback(
        self, callback: Callable[[HypothesisFeedbackRecord], None]
    ) -> None:
        """Register a callback for hypothesis accuracy."""
        with self._lock:
            self._on_hypothesis_accuracy.append(callback)

    def register_agent_performance_callback(
        self, callback: Callable[[AgentPerformanceRecord], None]
    ) -> None:
        """Register a callback for agent performance."""
        with self._lock:
            self._on_agent_performance.append(callback)

    def get_stats(self) -> Dict[str, Any]:
        """Get statistics about collected feedback.

        Returns:
            Dictionary with feedback statistics.
        """
        with self._lock:
            fix_outcomes = defaultdict(int)
            for r in self._fix_records:
                fix_outcomes[r.outcome.value] += 1

            hypothesis_accuracy = (
                sum(1 for r in self._hypothesis_records if r.was_correct)
                / len(self._hypothesis_records)
                if self._hypothesis_records
                else 0.0
            )

            agent_stats = {}
            for agent, records in self._agent_by_name.items():
                if records:
                    accuracies = [r.accuracy for r in records]
                    agent_stats[agent] = {
                        "count": len(records),
                        "mean_accuracy": statistics.mean(accuracies),
                        "win_rate": sum(1 for r in records if r.was_winner)
                        / len(records),
                    }

            return {
                "fix_records_count": len(self._fix_records),
                "hypothesis_records_count": len(self._hypothesis_records),
                "agent_records_count": len(self._agent_records),
                "fix_outcomes": dict(fix_outcomes),
                "overall_hypothesis_accuracy": hypothesis_accuracy,
                "agent_stats": agent_stats,
            }

    def cleanup_old_records(self) -> int:
        """Remove records older than retention period.

        Returns:
            Number of records removed.
        """
        cutoff = datetime.now(timezone.utc) - timedelta(days=self._retention_days)
        removed = 0

        with self._lock:
            # Clean fix records
            original = len(self._fix_records)
            self._fix_records = [r for r in self._fix_records if r.timestamp >= cutoff]
            removed += original - len(self._fix_records)

            # Rebuild fix index
            self._fix_by_id = {r.fix_id: r for r in self._fix_records}

            # Clean hypothesis records
            original = len(self._hypothesis_records)
            self._hypothesis_records = [
                r for r in self._hypothesis_records if r.timestamp >= cutoff
            ]
            removed += original - len(self._hypothesis_records)

            # Rebuild hypothesis index
            self._hypothesis_by_id = {
                r.hypothesis_id: r for r in self._hypothesis_records
            }

            # Clean agent records
            original = len(self._agent_records)
            self._agent_records = [
                r for r in self._agent_records if r.timestamp >= cutoff
            ]
            removed += original - len(self._agent_records)

            # Rebuild agent index
            self._agent_by_name = defaultdict(list)
            for r in self._agent_records:
                self._agent_by_name[r.agent_name].append(r)

        logger.info("Cleaned up %d old feedback records", removed)
        return removed

    def _maybe_trim_fix_records(self) -> None:
        """Trim fix records if over limit (called with lock held)."""
        if len(self._fix_records) > self._max_records:
            # Keep most recent half
            self._fix_records = self._fix_records[-(self._max_records // 2) :]
            self._fix_by_id = {r.fix_id: r for r in self._fix_records}
            logger.debug("Trimmed fix records to %d", len(self._fix_records))

    def _maybe_trim_hypothesis_records(self) -> None:
        """Trim hypothesis records if over limit (called with lock held)."""
        if len(self._hypothesis_records) > self._max_records:
            self._hypothesis_records = self._hypothesis_records[
                -(self._max_records // 2) :
            ]
            self._hypothesis_by_id = {
                r.hypothesis_id: r for r in self._hypothesis_records
            }
            logger.debug(
                "Trimmed hypothesis records to %d", len(self._hypothesis_records)
            )

    def _maybe_trim_agent_records(self) -> None:
        """Trim agent records if over limit (called with lock held)."""
        if len(self._agent_records) > self._max_records:
            self._agent_records = self._agent_records[-(self._max_records // 2) :]
            self._agent_by_name = defaultdict(list)
            for r in self._agent_records:
                self._agent_by_name[r.agent_name].append(r)
            logger.debug("Trimmed agent records to %d", len(self._agent_records))


# =============================================================================
# Feedback Learner
# =============================================================================


class FeedbackLearner:
    """Learns from collected feedback to improve future predictions.

    Uses exponential moving averages and statistical analysis to
    update weights based on historical performance.

    Example:
        >>> learner = FeedbackLearner(collector)
        >>> embedding_weights = await learner.update_embedding_weights(outcomes)
        >>> agent_weights = await learner.update_agent_weights(performances)
        >>> strategy_priorities = await learner.update_strategy_priorities(outcomes)
    """

    # Default weights for different embedding components
    DEFAULT_EMBEDDING_WEIGHTS = EmbeddingWeights(
        error_embedding_weight=0.4,
        code_before_weight=0.2,
        code_after_weight=0.2,
        description_weight=0.2,
    )

    # Default agent weights
    DEFAULT_AGENT_WEIGHTS = AgentWeights(
        weights={
            "timing_expert": 1.0,
            "state_expert": 1.0,
            "ordering_expert": 1.0,
            "network_expert": 1.0,
            "resource_expert": 1.0,
            "concurrency_expert": 1.0,
        }
    )

    # Default strategy priorities
    DEFAULT_STRATEGY_PRIORITIES = StrategyPriorities(
        priorities={
            "conservative": 0.7,
            "rag_based": 0.8,
            "framework_specific": 0.6,
            "creative": 0.4,
            "ensemble": 0.9,
        }
    )

    def __init__(
        self,
        collector: Optional[FeedbackCollector] = None,
        *,
        learning_rate: float = 0.1,
        min_samples: int = 10,
        confidence_threshold: float = 0.6,
        persist_path: Optional[str] = None,
    ):
        """Initialize the feedback learner.

        Args:
            collector: FeedbackCollector instance for data access.
            learning_rate: Rate for exponential moving average updates.
            min_samples: Minimum samples before adjusting weights.
            confidence_threshold: Minimum confidence for weight changes.
        """
        self._collector = collector
        self._learning_rate = learning_rate
        self._min_samples = min_samples
        self._confidence_threshold = confidence_threshold
        self._persist_path = Path(persist_path) if persist_path else None

        # Current weights (start with defaults)
        self._current_embedding_weights = self.DEFAULT_EMBEDDING_WEIGHTS
        self._current_agent_weights = self.DEFAULT_AGENT_WEIGHTS
        self._current_strategy_priorities = self.DEFAULT_STRATEGY_PRIORITIES

        # Weight update history for analysis
        self._weight_history: List[Dict[str, Any]] = []
        if self._persist_path is not None:
            self.load_state(self._persist_path)

        logger.debug(
            "FeedbackLearner initialized with learning_rate=%.2f, min_samples=%d",
            learning_rate,
            min_samples,
        )

    @property
    def collector(self) -> Optional[FeedbackCollector]:
        """Get the feedback collector."""
        return self._collector

    @collector.setter
    def collector(self, value: FeedbackCollector) -> None:
        """Set the feedback collector."""
        self._collector = value

    async def update_embedding_weights(
        self,
        outcomes: Optional[List[FixFeedbackRecord]] = None,
    ) -> EmbeddingWeights:
        """Update embedding weights based on fix outcomes.

        Analyzes which embedding combinations led to successful fixes
        and adjusts weights accordingly using exponential moving average.

        Args:
            outcomes: Optional list of outcomes to use. If not provided,
                     fetches recent outcomes from collector.

        Returns:
            Updated EmbeddingWeights.
        """
        if outcomes is None and self._collector:
            outcomes = self._collector.get_fix_outcomes(limit=1000)

        if not outcomes or len(outcomes) < self._min_samples:
            logger.debug(
                "Not enough samples for embedding weight update: %d < %d",
                len(outcomes) if outcomes else 0,
                self._min_samples,
            )
            return self._current_embedding_weights

        # Group outcomes by similarity score ranges
        # Higher similarity scores should correlate with better outcomes
        successful_scores: List[float] = []
        failed_scores: List[float] = []

        for record in outcomes:
            if record.similarity_score is None:
                continue

            if record.outcome in (FixOutcome.ACCEPTED, FixOutcome.MODIFIED):
                successful_scores.append(record.similarity_score)
            else:
                failed_scores.append(record.similarity_score)

        if not successful_scores or not failed_scores:
            return self._current_embedding_weights

        # Calculate score differential
        avg_success = statistics.mean(successful_scores)
        avg_failure = statistics.mean(failed_scores)

        # If successful fixes have higher similarity, embeddings are working well
        score_diff = avg_success - avg_failure

        # Analyze category-specific performance
        category_weights = dict(self._current_embedding_weights.category_weights)
        category_outcomes: Dict[str, List[FixFeedbackRecord]] = defaultdict(list)
        for record in outcomes:
            if record.category:
                category_outcomes[record.category].append(record)

        for category, cat_records in category_outcomes.items():
            if len(cat_records) < self._min_samples:
                continue

            success_rate = sum(
                1
                for r in cat_records
                if r.outcome in (FixOutcome.ACCEPTED, FixOutcome.MODIFIED)
            ) / len(cat_records)

            # Adjust category weight based on success rate
            current_weight = category_weights.get(category, 1.0)
            new_weight = current_weight + self._learning_rate * (success_rate - 0.5)
            category_weights[category] = max(0.5, min(1.5, new_weight))

        # Create new weights with slight adjustment based on score differential
        # If similarity correlates with success, increase error embedding weight
        adjustment = self._learning_rate * score_diff * 0.1
        adjustment = max(-0.05, min(0.05, adjustment))  # Clamp adjustment

        new_weights = EmbeddingWeights(
            error_embedding_weight=max(
                0.2,
                min(0.6, self._current_embedding_weights.error_embedding_weight + adjustment),
            ),
            code_before_weight=max(
                0.1,
                min(0.3, self._current_embedding_weights.code_before_weight - adjustment / 2),
            ),
            code_after_weight=max(
                0.1,
                min(0.3, self._current_embedding_weights.code_after_weight - adjustment / 4),
            ),
            description_weight=max(
                0.1,
                min(0.3, self._current_embedding_weights.description_weight - adjustment / 4),
            ),
            category_weights=category_weights,
            version=self._current_embedding_weights.version + 1,
        )

        # Record history
        self._weight_history.append(
            {
                "type": "embedding_weights",
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "old_weights": self._current_embedding_weights.to_dict(),
                "new_weights": new_weights.to_dict(),
                "score_diff": score_diff,
                "samples": len(outcomes),
            }
        )

        self._current_embedding_weights = new_weights
        self._persist_if_configured()

        logger.info(
            "Updated embedding weights: error=%.2f, score_diff=%.3f, samples=%d",
            new_weights.error_embedding_weight,
            score_diff,
            len(outcomes),
        )

        return new_weights

    async def update_agent_weights(
        self,
        performances: Optional[List[AgentPerformanceRecord]] = None,
    ) -> AgentWeights:
        """Update agent weights based on performance history.

        Agents with higher accuracy get higher weights in debates.

        Args:
            performances: Optional list of performances to use.
                         If not provided, fetches from collector.

        Returns:
            Updated AgentWeights.
        """
        if performances is None and self._collector:
            performances = []
            for agent in self._current_agent_weights.weights:
                performances.extend(
                    self._collector.get_agent_performances(agent, limit=500)
                )

        if not performances or len(performances) < self._min_samples:
            logger.debug(
                "Not enough samples for agent weight update: %d < %d",
                len(performances) if performances else 0,
                self._min_samples,
            )
            return self._current_agent_weights

        # Group by agent
        agent_performances: Dict[str, List[float]] = defaultdict(list)
        agent_wins: Dict[str, int] = defaultdict(int)
        agent_total: Dict[str, int] = defaultdict(int)

        for record in performances:
            agent_performances[record.agent_name].append(record.accuracy)
            agent_total[record.agent_name] += 1
            if record.was_winner:
                agent_wins[record.agent_name] += 1

        # Calculate new weights
        new_weights: Dict[str, float] = {}
        accuracy_scores: Dict[str, float] = {}
        sample_counts: Dict[str, int] = {}

        for agent, accuracies in agent_performances.items():
            sample_counts[agent] = len(accuracies)

            if len(accuracies) < self._min_samples:
                # Keep current weight if not enough samples
                new_weights[agent] = self._current_agent_weights.get_weight(agent)
                continue

            # Calculate mean accuracy
            mean_accuracy = statistics.mean(accuracies)
            accuracy_scores[agent] = mean_accuracy

            # Calculate win rate bonus
            win_rate = agent_wins[agent] / agent_total[agent] if agent_total[agent] > 0 else 0

            # Combine accuracy and win rate
            combined_score = 0.7 * mean_accuracy + 0.3 * win_rate

            # Exponential moving average update
            current_weight = self._current_agent_weights.get_weight(agent)
            new_weight = (
                current_weight
                + self._learning_rate * (combined_score - current_weight)
            )

            # Clamp to reasonable range
            new_weights[agent] = max(0.5, min(1.5, new_weight))

        # Ensure all known agents have weights
        for agent in self._current_agent_weights.weights:
            if agent not in new_weights:
                new_weights[agent] = self._current_agent_weights.weights[agent]

        result = AgentWeights(
            weights=new_weights,
            accuracy_scores=accuracy_scores,
            sample_counts=sample_counts,
            min_samples_for_adjustment=self._min_samples,
            version=self._current_agent_weights.version + 1,
        )

        # Record history
        self._weight_history.append(
            {
                "type": "agent_weights",
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "old_weights": self._current_agent_weights.to_dict(),
                "new_weights": result.to_dict(),
                "samples": len(performances),
            }
        )

        self._current_agent_weights = result
        self._persist_if_configured()

        logger.info(
            "Updated agent weights for %d agents, samples=%d",
            len(new_weights),
            len(performances),
        )

        return result

    async def update_strategy_priorities(
        self,
        outcomes: Optional[List[FixFeedbackRecord]] = None,
    ) -> StrategyPriorities:
        """Update strategy priorities based on fix outcomes.

        Strategies with higher success rates get higher priorities.

        Args:
            outcomes: Optional list of outcomes to use.
                     If not provided, fetches from collector.

        Returns:
            Updated StrategyPriorities.
        """
        if outcomes is None and self._collector:
            outcomes = self._collector.get_fix_outcomes(limit=1000)

        if not outcomes or len(outcomes) < self._min_samples:
            logger.debug(
                "Not enough samples for strategy priority update: %d < %d",
                len(outcomes) if outcomes else 0,
                self._min_samples,
            )
            return self._current_strategy_priorities

        # Group by strategy
        strategy_outcomes: Dict[str, List[FixOutcome]] = defaultdict(list)
        category_strategy_outcomes: Dict[
            str, Dict[str, List[FixOutcome]]
        ] = defaultdict(lambda: defaultdict(list))

        for record in outcomes:
            if record.strategy:
                strategy_outcomes[record.strategy].append(record.outcome)
                if record.category:
                    category_strategy_outcomes[record.category][record.strategy].append(
                        record.outcome
                    )

        # Calculate new priorities
        new_priorities: Dict[str, float] = {}
        success_rates: Dict[str, float] = {}
        attempt_counts: Dict[str, int] = {}

        for strategy, outs in strategy_outcomes.items():
            attempt_counts[strategy] = len(outs)

            if len(outs) < self._min_samples:
                # Keep current priority if not enough samples
                new_priorities[strategy] = self._current_strategy_priorities.get_priority(
                    strategy
                )
                continue

            # Calculate success rate
            successes = sum(
                1 for o in outs if o in (FixOutcome.ACCEPTED, FixOutcome.MODIFIED)
            )
            success_rate = successes / len(outs)
            success_rates[strategy] = success_rate

            # Penalize FAILED_VERIFICATION more heavily
            verification_failures = sum(1 for o in outs if o == FixOutcome.FAILED_VERIFICATION)
            verification_penalty = verification_failures / len(outs) * 0.2

            adjusted_rate = success_rate - verification_penalty

            # Exponential moving average update
            current_priority = self._current_strategy_priorities.get_priority(strategy)
            new_priority = (
                current_priority
                + self._learning_rate * (adjusted_rate - current_priority)
            )

            # Clamp to reasonable range
            new_priorities[strategy] = max(0.1, min(0.95, new_priority))

        # Calculate category-specific priorities
        category_priorities: Dict[str, Dict[str, float]] = {}

        for category, strategies in category_strategy_outcomes.items():
            category_priorities[category] = {}
            for strategy, outs in strategies.items():
                if len(outs) < self._min_samples // 2:  # Lower threshold for category-specific
                    continue

                successes = sum(
                    1 for o in outs if o in (FixOutcome.ACCEPTED, FixOutcome.MODIFIED)
                )
                success_rate = successes / len(outs)

                # Use higher learning rate for category-specific
                current = self._current_strategy_priorities.get_priority(
                    strategy, category
                )
                new_priority = current + (self._learning_rate * 1.5) * (
                    success_rate - current
                )
                category_priorities[category][strategy] = max(0.1, min(0.95, new_priority))

        # Ensure all known strategies have priorities
        for strategy in self._current_strategy_priorities.priorities:
            if strategy not in new_priorities:
                new_priorities[strategy] = self._current_strategy_priorities.priorities[
                    strategy
                ]

        result = StrategyPriorities(
            priorities=new_priorities,
            success_rates=success_rates,
            attempt_counts=attempt_counts,
            category_priorities=category_priorities,
            version=self._current_strategy_priorities.version + 1,
        )

        # Record history
        self._weight_history.append(
            {
                "type": "strategy_priorities",
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "old_priorities": self._current_strategy_priorities.to_dict(),
                "new_priorities": result.to_dict(),
                "samples": len(outcomes),
            }
        )

        self._current_strategy_priorities = result
        self._persist_if_configured()

        logger.info(
            "Updated strategy priorities for %d strategies, samples=%d",
            len(new_priorities),
            len(outcomes),
        )

        return result

    def _persist_if_configured(self) -> None:
        if self._persist_path is None:
            return
        self.save_state(self._persist_path)

    def save_state(self, path: str | Path) -> None:
        """Persist learned weights to disk."""
        state = {
            "embedding_weights": self._current_embedding_weights.to_dict(),
            "agent_weights": self._current_agent_weights.to_dict(),
            "strategy_priorities": self._current_strategy_priorities.to_dict(),
            "weight_history": self._weight_history[-500:],
            "saved_at": datetime.now(timezone.utc).isoformat(),
        }
        target = Path(path)
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(json.dumps(state, indent=2))

    def load_state(self, path: str | Path) -> bool:
        """Load learned weights from disk if present."""
        source = Path(path)
        if not source.exists():
            return False
        try:
            data = json.loads(source.read_text())
            if "embedding_weights" in data:
                self._current_embedding_weights = EmbeddingWeights.from_dict(
                    data["embedding_weights"]
                )
            if "agent_weights" in data:
                self._current_agent_weights = AgentWeights.from_dict(
                    data["agent_weights"]
                )
            if "strategy_priorities" in data:
                self._current_strategy_priorities = StrategyPriorities.from_dict(
                    data["strategy_priorities"]
                )
            if isinstance(data.get("weight_history"), list):
                self._weight_history = data["weight_history"][-1000:]
            logger.info("Loaded feedback learner state from %s", source)
            return True
        except Exception as e:
            logger.warning("Failed to load feedback learner state from %s: %s", source, e)
            return False

    async def run_full_update(self) -> Dict[str, Any]:
        """Run a full update of all weights and priorities.

        Returns:
            Dictionary with all updated weights and metadata.
        """
        embedding_weights = await self.update_embedding_weights()
        agent_weights = await self.update_agent_weights()
        strategy_priorities = await self.update_strategy_priorities()

        return {
            "embedding_weights": embedding_weights.to_dict(),
            "agent_weights": agent_weights.to_dict(),
            "strategy_priorities": strategy_priorities.to_dict(),
            "updated_at": datetime.now(timezone.utc).isoformat(),
        }

    def get_current_weights(self) -> Dict[str, Any]:
        """Get all current weights and priorities.

        Returns:
            Dictionary with current weights.
        """
        return {
            "embedding_weights": self._current_embedding_weights.to_dict(),
            "agent_weights": self._current_agent_weights.to_dict(),
            "strategy_priorities": self._current_strategy_priorities.to_dict(),
        }

    def get_weight_history(self, limit: int = 100) -> List[Dict[str, Any]]:
        """Get weight update history.

        Args:
            limit: Maximum history entries to return.

        Returns:
            List of weight update records.
        """
        return self._weight_history[-limit:]

    def reset_to_defaults(self) -> None:
        """Reset all weights to defaults."""
        self._current_embedding_weights = self.DEFAULT_EMBEDDING_WEIGHTS
        self._current_agent_weights = self.DEFAULT_AGENT_WEIGHTS
        self._current_strategy_priorities = self.DEFAULT_STRATEGY_PRIORITIES
        self._weight_history.clear()
        self._persist_if_configured()
        logger.info("Reset all weights to defaults")


# =============================================================================
# Convenience Functions
# =============================================================================


_default_collector: Optional[FeedbackCollector] = None
_default_learner: Optional[FeedbackLearner] = None
_init_lock = threading.Lock()


def get_default_collector() -> FeedbackCollector:
    """Get or create the default feedback collector singleton."""
    global _default_collector
    with _init_lock:
        if _default_collector is None:
            _default_collector = FeedbackCollector()
        return _default_collector


def get_default_learner() -> FeedbackLearner:
    """Get or create the default feedback learner singleton."""
    global _default_learner
    with _init_lock:
        if _default_learner is None:
            _default_learner = FeedbackLearner(get_default_collector())
        return _default_learner


async def record_and_learn(
    fix_id: str,
    outcome: FixOutcome,
    **kwargs: Any,
) -> Dict[str, Any]:
    """Convenience function to record feedback and trigger learning.

    Args:
        fix_id: Fix identifier.
        outcome: Fix outcome.
        **kwargs: Additional context for the fix record.

    Returns:
        Updated weights after learning.
    """
    collector = get_default_collector()
    learner = get_default_learner()

    collector.record_fix_outcome(fix_id, outcome, **kwargs)

    # Trigger learning update
    return await learner.run_full_update()
