"""
InsAIts SDK - Semantic Drift Detector
======================================
Detects meaning drift between sequential agent messages using
cosine distance and Exponentially Weighted Moving Average (EWMA).

Algorithm:
    1. Compute cosine distance between current and previous message embeddings
    2. Maintain a rolling EWMA of distances as the baseline
    3. Flag when current distance exceeds (baseline_mean + k * baseline_std)

Parameters:
    sensitivity (k): Multiplier for standard deviation threshold.
        2.0 = ~95th percentile (default), 1.5 = more sensitive, 3.0 = less sensitive
    min_window: Minimum messages before detection activates (warmup period)
"""

import numpy as np
from dataclasses import dataclass
from typing import List, Optional


@dataclass(frozen=True)
class DriftResult:
    """Result of a semantic drift check.

    Attributes:
        detected: Whether drift was flagged
        score: Cosine distance (0.0 = identical, 1.0 = orthogonal)
        baseline: Current EWMA baseline mean
        threshold: Dynamic threshold at time of check
        window: Number of messages in the baseline window
        description: Human-readable explanation
    """
    detected: bool
    score: float
    baseline: float
    threshold: float
    window: int
    description: str


class SemanticDriftDetector:
    """Detects semantic drift between sequential agent messages.

    Uses cosine distance between sentence embeddings with an
    Exponentially Weighted Moving Average (EWMA) baseline.

    A message is flagged when:
        cosine_distance(current, prev) > (baseline_mean + k * baseline_std)

    where k is the sensitivity multiplier (default 2.0).
    """

    name = "semantic_drift"
    enabled = True

    # EWMA smoothing factor (higher = more weight on recent observations)
    EWMA_ALPHA = 0.3

    def __init__(self, sensitivity: float = 2.0, min_window: int = 3):
        """Initialize the drift detector.

        Args:
            sensitivity: Standard deviation multiplier for threshold (default 2.0)
            min_window: Minimum messages before detection activates
        """
        if sensitivity <= 0:
            raise ValueError("sensitivity must be positive")
        if min_window < 1:
            raise ValueError("min_window must be at least 1")

        self.sensitivity = sensitivity
        self.min_window = min_window
        self._history: List[np.ndarray] = []
        self._distances: List[float] = []
        self._ewma_mean: Optional[float] = None
        self._ewma_var: Optional[float] = None

    def check(self, embedding: np.ndarray) -> DriftResult:
        """Check a new message embedding for semantic drift.

        Args:
            embedding: Normalized embedding vector for the current message

        Returns:
            DriftResult with detection outcome and metrics
        """
        if len(self._history) == 0:
            self._history.append(np.array(embedding, copy=True))
            return DriftResult(
                detected=False,
                score=0.0,
                baseline=0.0,
                threshold=0.0,
                window=0,
                description="First message -- baseline not yet established.",
            )

        # Cosine distance to previous message
        prev = self._history[-1]
        score = self._cosine_distance(embedding, prev)

        self._history.append(np.array(embedding, copy=True))
        self._distances.append(score)

        # Update EWMA statistics
        self._update_ewma(score)

        ewma_std = float(np.sqrt(max(self._ewma_var, 0.0)))
        threshold = self._ewma_mean + self.sensitivity * ewma_std
        window = len(self._distances)

        # Warmup period: don't flag until we have enough history
        if window < self.min_window:
            return DriftResult(
                detected=False,
                score=round(score, 4),
                baseline=round(self._ewma_mean, 4),
                threshold=round(threshold, 4),
                window=window,
                description=f"Warming up ({window}/{self.min_window} messages).",
            )

        detected = score > threshold
        if detected:
            description = (
                f"Drift score {score:.3f} exceeds dynamic threshold "
                f"{threshold:.3f} (baseline: {self._ewma_mean:.3f} "
                f"+/- {ewma_std:.3f})"
            )
        else:
            description = (
                f"Score {score:.3f} within normal range "
                f"(threshold: {threshold:.3f})."
            )

        return DriftResult(
            detected=detected,
            score=round(score, 4),
            baseline=round(self._ewma_mean, 4),
            threshold=round(threshold, 4),
            window=window,
            description=description,
        )

    def reset(self) -> None:
        """Reset detector state. Use when starting a new conversation."""
        self._history.clear()
        self._distances.clear()
        self._ewma_mean = None
        self._ewma_var = None

    def _cosine_distance(self, a: np.ndarray, b: np.ndarray) -> float:
        """Compute cosine distance (1 - cosine_similarity).

        Returns value in [0, 2] where 0 = identical, 1 = orthogonal.
        """
        norm_a = np.linalg.norm(a)
        norm_b = np.linalg.norm(b)
        if norm_a < 1e-8 or norm_b < 1e-8:
            return 1.0
        similarity = float(np.dot(a, b) / (norm_a * norm_b))
        # Clamp to valid range before converting to distance
        similarity = max(-1.0, min(1.0, similarity))
        return 1.0 - similarity

    def _update_ewma(self, score: float) -> None:
        """Update EWMA mean and variance with a new observation."""
        alpha = self.EWMA_ALPHA
        if self._ewma_mean is None:
            self._ewma_mean = score
            self._ewma_var = 0.0
        else:
            delta = score - self._ewma_mean
            self._ewma_mean = self._ewma_mean + alpha * delta
            self._ewma_var = (1 - alpha) * (
                self._ewma_var + alpha * delta * delta
            )
