"""
Context propagation observability module.

Provides metrics, logging, and diagnostics for context operations.
Makes context loss visible and debuggable.

Design Principles:
1. Zero required dependencies (prometheus_client is optional)
2. Minimal overhead when disabled
3. Structured logging for debugging
4. Thread-safe counters

Usage:
    from risicare_core.observability import (
        context_metrics,
        enable_context_observability,
        get_context_health_report,
    )

    # Enable observability (call once at startup)
    enable_context_observability(log_level="DEBUG")

    # Get health report
    report = get_context_health_report()
    print(f"Context loss rate: {report['loss_rate']:.4%}")
"""

from __future__ import annotations

import inspect
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar

# =============================================================================
# Configuration
# =============================================================================

@dataclass
class ObservabilityConfig:
    """Configuration for context observability."""

    enabled: bool = False
    log_level: str = "WARNING"
    log_context_enter_exit: bool = False
    track_context_loss: bool = True
    track_timing: bool = False
    sample_rate: float = 1.0  # 1.0 = 100% sampling
    max_loss_locations: int = 100  # Limit memory for location tracking


_config = ObservabilityConfig()


def enable_context_observability(
    log_level: str = "WARNING",
    log_context_enter_exit: bool = False,
    track_timing: bool = False,
    sample_rate: float = 1.0,
) -> None:
    """
    Enable context observability.

    Call once at application startup to enable metrics and logging.

    Args:
        log_level: Logging level for context operations (DEBUG, INFO, WARNING).
        log_context_enter_exit: Log every context enter/exit (verbose).
        track_timing: Track timing of context operations.
        sample_rate: Fraction of operations to sample (0.0 to 1.0).

    Example:
        import risicare
        from risicare_core.observability import enable_context_observability

        enable_context_observability(log_level="DEBUG", log_context_enter_exit=True)
        risicare.init(...)
    """
    global _config
    _config = ObservabilityConfig(
        enabled=True,
        log_level=log_level,
        log_context_enter_exit=log_context_enter_exit,
        track_context_loss=True,
        track_timing=track_timing,
        sample_rate=sample_rate,
    )

    # Configure logger
    logger = logging.getLogger("risicare.context")
    logger.setLevel(getattr(logging, log_level.upper(), logging.WARNING))

    logger.info(
        "Context observability enabled",
        extra={
            "log_level": log_level,
            "log_enter_exit": log_context_enter_exit,
            "track_timing": track_timing,
            "sample_rate": sample_rate,
        },
    )


def disable_context_observability() -> None:
    """Disable context observability."""
    global _config
    _config = ObservabilityConfig(enabled=False)


def is_observability_enabled() -> bool:
    """Check if observability is enabled."""
    return _config.enabled


# =============================================================================
# Context Types
# =============================================================================

class ContextType(Enum):
    """Types of context that can be tracked."""

    SESSION = "session"
    AGENT = "agent"
    SPAN = "span"
    PHASE = "phase"


class ContextOperation(Enum):
    """Types of context operations."""

    ENTER = "enter"
    EXIT = "exit"
    GET = "get"
    LOSS = "loss"  # Context unexpectedly None


# =============================================================================
# Thread-Safe Counters (No Dependencies)
# =============================================================================

@dataclass
class Counter:
    """Thread-safe counter with labels."""

    name: str
    description: str
    _values: Dict[Tuple[Tuple[str, str], ...], int] = field(default_factory=dict)
    _lock: threading.Lock = field(default_factory=threading.Lock)

    def inc(self, labels: Optional[Dict[str, str]] = None, value: int = 1) -> None:
        """Increment counter."""
        key = tuple(sorted((labels or {}).items()))
        with self._lock:
            self._values[key] = self._values.get(key, 0) + value

    def get(self, labels: Optional[Dict[str, str]] = None) -> int:
        """Get counter value."""
        key = tuple(sorted((labels or {}).items()))
        with self._lock:
            return self._values.get(key, 0)

    def get_all(self) -> Dict[Tuple[Tuple[str, str], ...], int]:
        """Get all counter values."""
        with self._lock:
            return dict(self._values)

    def reset(self) -> None:
        """Reset all counters."""
        with self._lock:
            self._values.clear()


@dataclass
class Histogram:
    """Simple histogram for timing (no dependencies)."""

    name: str
    description: str
    buckets: Tuple[float, ...] = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0)
    _observations: List[float] = field(default_factory=list)
    _lock: threading.Lock = field(default_factory=threading.Lock)
    _max_observations: int = 10000

    def observe(self, value: float) -> None:
        """Record an observation."""
        with self._lock:
            self._observations.append(value)
            # Prevent unbounded memory growth
            if len(self._observations) > self._max_observations:
                self._observations = self._observations[-self._max_observations:]

    def get_percentile(self, percentile: float) -> float:
        """Get a percentile value (0-100)."""
        with self._lock:
            if not self._observations:
                return 0.0
            sorted_obs = sorted(self._observations)
            idx = int(len(sorted_obs) * percentile / 100)
            return sorted_obs[min(idx, len(sorted_obs) - 1)]

    def get_count(self) -> int:
        """Get total observation count."""
        with self._lock:
            return len(self._observations)

    def get_sum(self) -> float:
        """Get sum of all observations."""
        with self._lock:
            return sum(self._observations)

    def reset(self) -> None:
        """Reset histogram."""
        with self._lock:
            self._observations.clear()


# =============================================================================
# Context Loss Tracking
# =============================================================================

@dataclass
class ContextLossEvent:
    """Record of a context loss event."""

    context_type: ContextType
    location: str  # file:line:function
    timestamp: datetime
    expected_context: bool  # True if we were inside a traced scope
    thread_id: int
    thread_name: str


class ContextLossTracker:
    """
    Tracks context loss events for debugging.

    Thread-safe with bounded memory usage.
    """

    def __init__(self, max_events: int = 1000, max_locations: int = 100):
        self._events: List[ContextLossEvent] = []
        self._location_counts: Dict[str, int] = {}
        self._lock = threading.Lock()
        self._max_events = max_events
        self._max_locations = max_locations

    def record_loss(
        self,
        context_type: ContextType,
        expected_context: bool = False,
        skip_frames: int = 2,
    ) -> None:
        """
        Record a context loss event.

        Args:
            context_type: Which context type was lost.
            expected_context: True if we expected context to exist.
            skip_frames: Stack frames to skip for location.
        """
        if not _config.enabled or not _config.track_context_loss:
            return

        # Get caller location
        frame = inspect.currentframe()
        for _ in range(skip_frames):
            if frame is not None:
                frame = frame.f_back

        if frame is not None:
            filename = frame.f_code.co_filename
            lineno = frame.f_lineno
            funcname = frame.f_code.co_name
            # Shorten path
            if "/risicare" in filename:
                filename = filename.split("/risicare")[-1]
            location = f"{filename}:{lineno}:{funcname}"
        else:
            location = "unknown"

        thread = threading.current_thread()

        event = ContextLossEvent(
            context_type=context_type,
            location=location,
            timestamp=datetime.now(timezone.utc),
            expected_context=expected_context,
            thread_id=thread.ident or 0,
            thread_name=thread.name,
        )

        with self._lock:
            # Add event (bounded)
            self._events.append(event)
            if len(self._events) > self._max_events:
                self._events = self._events[-self._max_events:]

            # Track location counts (bounded)
            if len(self._location_counts) < self._max_locations or location in self._location_counts:
                self._location_counts[location] = self._location_counts.get(location, 0) + 1

    def get_recent_events(self, count: int = 10) -> List[ContextLossEvent]:
        """Get most recent loss events."""
        with self._lock:
            return list(self._events[-count:])

    def get_top_locations(self, count: int = 10) -> List[Tuple[str, int]]:
        """Get locations with most context loss."""
        with self._lock:
            sorted_locs = sorted(
                self._location_counts.items(),
                key=lambda x: x[1],
                reverse=True,
            )
            return sorted_locs[:count]

    def get_total_losses(self) -> int:
        """Get total number of recorded losses."""
        with self._lock:
            return len(self._events)

    def reset(self) -> None:
        """Reset all tracking."""
        with self._lock:
            self._events.clear()
            self._location_counts.clear()


# =============================================================================
# Global Metrics Instances
# =============================================================================

class ContextMetrics:
    """
    Central metrics registry for context operations.

    Thread-safe, can be accessed from anywhere.
    """

    def __init__(self) -> None:
        # Counters
        self.context_operations = Counter(
            name="risicare_context_operations_total",
            description="Total context operations by type and operation",
        )
        self.context_loss = Counter(
            name="risicare_context_loss_total",
            description="Context unexpectedly None when expected",
        )
        self.context_errors = Counter(
            name="risicare_context_errors_total",
            description="Errors during context operations",
        )

        # Histograms
        self.context_duration = Histogram(
            name="risicare_context_duration_seconds",
            description="Duration of context manager scopes",
        )

        # Loss tracker
        self.loss_tracker = ContextLossTracker()

        # Timing tracker for context managers
        self._active_contexts: Dict[int, float] = {}  # id -> start_time
        self._contexts_lock = threading.Lock()

    def record_operation(
        self,
        context_type: ContextType,
        operation: ContextOperation,
    ) -> None:
        """Record a context operation."""
        if not _config.enabled:
            return

        self.context_operations.inc({
            "context_type": context_type.value,
            "operation": operation.value,
        })

    def record_loss(
        self,
        context_type: ContextType,
        expected_context: bool = False,
        skip_frames: int = 3,
    ) -> None:
        """Record a context loss event."""
        if not _config.enabled:
            return

        self.context_loss.inc({
            "context_type": context_type.value,
            "expected": str(expected_context).lower(),
        })

        self.loss_tracker.record_loss(
            context_type=context_type,
            expected_context=expected_context,
            skip_frames=skip_frames,
        )

    def record_error(self, context_type: ContextType, error_type: str) -> None:
        """Record a context error."""
        if not _config.enabled:
            return

        self.context_errors.inc({
            "context_type": context_type.value,
            "error_type": error_type,
        })

    def start_context_timing(self, context_id: int) -> None:
        """Start timing a context manager."""
        if not _config.enabled or not _config.track_timing:
            return

        with self._contexts_lock:
            self._active_contexts[context_id] = time.perf_counter()

    def end_context_timing(self, context_id: int, context_type: ContextType) -> None:
        """End timing a context manager and record duration."""
        if not _config.enabled or not _config.track_timing:
            return

        with self._contexts_lock:
            start_time = self._active_contexts.pop(context_id, None)

        if start_time is not None:
            duration = time.perf_counter() - start_time
            self.context_duration.observe(duration)

    def reset_all(self) -> None:
        """Reset all metrics (for testing)."""
        self.context_operations.reset()
        self.context_loss.reset()
        self.context_errors.reset()
        self.context_duration.reset()
        self.loss_tracker.reset()


# Global instance
context_metrics = ContextMetrics()


# =============================================================================
# P2-7: Self-Observability Counters
# =============================================================================

classification_fast_total = Counter(
    name="risicare_classification_fast_total",
    description="Total fast_classify invocations",
)

classification_llm_total = Counter(
    name="risicare_classification_llm_total",
    description="Total LLM-based classify invocations",
)

classification_fast_miss_total = Counter(
    name="risicare_classification_fast_miss_total",
    description="fast_classify calls that returned None (no pattern match)",
)

export_success_total = Counter(
    name="risicare_export_success_total",
    description="Successful span export operations",
)

export_failure_total = Counter(
    name="risicare_export_failure_total",
    description="Failed span export operations",
)

dedup_suppressed_total = Counter(
    name="risicare_dedup_suppressed_total",
    description="Provider span creations suppressed by dedup",
)


# =============================================================================
# Logging Utilities
# =============================================================================

_logger = logging.getLogger("risicare.context")


def log_context_enter(
    context_type: ContextType,
    context_id: str,
    **extra: Any,
) -> None:
    """Log context manager entry."""
    if not _config.enabled or not _config.log_context_enter_exit:
        return

    _logger.debug(
        f"Context {context_type.value} entered: {context_id}",
        extra={
            "context_type": context_type.value,
            "context_id": context_id,
            "operation": "enter",
            "thread_name": threading.current_thread().name,
            **extra,
        },
    )


def log_context_exit(
    context_type: ContextType,
    context_id: str,
    duration_ms: Optional[float] = None,
    **extra: Any,
) -> None:
    """Log context manager exit."""
    if not _config.enabled or not _config.log_context_enter_exit:
        return

    _logger.debug(
        f"Context {context_type.value} exited: {context_id}",
        extra={
            "context_type": context_type.value,
            "context_id": context_id,
            "operation": "exit",
            "duration_ms": duration_ms,
            "thread_name": threading.current_thread().name,
            **extra,
        },
    )


def log_context_loss(
    context_type: ContextType,
    location: str,
    expected: bool = False,
) -> None:
    """Log when context is unexpectedly None."""
    if not _config.enabled:
        return

    level = logging.WARNING if expected else logging.DEBUG
    _logger.log(
        level,
        f"Context {context_type.value} is None at {location}",
        extra={
            "context_type": context_type.value,
            "location": location,
            "expected": expected,
            "thread_name": threading.current_thread().name,
        },
    )


# =============================================================================
# Context Scope Detection
# =============================================================================

# Track whether we're inside a traced scope
_in_traced_scope: Dict[int, int] = {}  # thread_id -> nesting depth
_scope_lock = threading.Lock()


def enter_traced_scope() -> None:
    """Mark that we're entering a traced scope."""
    thread_id = threading.current_thread().ident or 0
    with _scope_lock:
        _in_traced_scope[thread_id] = _in_traced_scope.get(thread_id, 0) + 1


def exit_traced_scope() -> None:
    """Mark that we're exiting a traced scope."""
    thread_id = threading.current_thread().ident or 0
    with _scope_lock:
        depth = _in_traced_scope.get(thread_id, 0)
        if depth > 1:
            _in_traced_scope[thread_id] = depth - 1
        elif thread_id in _in_traced_scope:
            del _in_traced_scope[thread_id]


def is_inside_traced_scope() -> bool:
    """Check if current thread is inside a traced scope."""
    thread_id = threading.current_thread().ident or 0
    with _scope_lock:
        return _in_traced_scope.get(thread_id, 0) > 0


# =============================================================================
# Health Report
# =============================================================================

def get_context_health_report() -> Dict[str, Any]:
    """
    Get a comprehensive health report of context propagation.

    Returns:
        Dict with metrics, loss rate, and diagnostic information.

    Example:
        report = get_context_health_report()
        if report["loss_rate"] > 0.01:
            logger.warning(f"High context loss rate: {report['loss_rate']:.2%}")
    """
    ops = context_metrics.context_operations.get_all()
    losses = context_metrics.context_loss.get_all()

    # Calculate totals
    total_operations = sum(ops.values())
    total_losses = sum(losses.values())

    # Calculate loss rate
    loss_rate = total_losses / max(total_operations, 1)

    # Get timing percentiles
    p50 = context_metrics.context_duration.get_percentile(50)
    p99 = context_metrics.context_duration.get_percentile(99)

    return {
        "enabled": _config.enabled,
        "total_operations": total_operations,
        "total_losses": total_losses,
        "loss_rate": loss_rate,
        "operations_by_type": {
            str(k): v for k, v in ops.items()
        },
        "losses_by_type": {
            str(k): v for k, v in losses.items()
        },
        "timing": {
            "p50_seconds": p50,
            "p99_seconds": p99,
            "total_observations": context_metrics.context_duration.get_count(),
        },
        "top_loss_locations": context_metrics.loss_tracker.get_top_locations(5),
        "recent_loss_events": [
            {
                "context_type": e.context_type.value,
                "location": e.location,
                "timestamp": e.timestamp.isoformat(),
                "thread": e.thread_name,
            }
            for e in context_metrics.loss_tracker.get_recent_events(5)
        ],
    }


# =============================================================================
# Prometheus Export (Optional)
# =============================================================================

def get_prometheus_metrics() -> Optional[str]:
    """
    Export metrics in Prometheus format.

    Returns:
        Prometheus-formatted metrics string, or None if prometheus_client
        is not available.

    Example:
        metrics = get_prometheus_metrics()
        if metrics:
            # Expose at /metrics endpoint
            pass
    """
    try:
        from prometheus_client import (
            CollectorRegistry,
            Counter as PromCounter,
            Histogram as PromHistogram,
            generate_latest,
        )
    except ImportError:
        return None

    registry = CollectorRegistry()

    # Operations counter
    ops_counter = PromCounter(
        "risicare_context_operations_total",
        "Total context operations",
        ["context_type", "operation"],
        registry=registry,
    )
    for labels, value in context_metrics.context_operations.get_all().items():
        labels_dict = dict(labels)
        ops_counter.labels(**labels_dict).inc(value)

    # Loss counter
    loss_counter = PromCounter(
        "risicare_context_loss_total",
        "Context loss events",
        ["context_type", "expected"],
        registry=registry,
    )
    for labels, value in context_metrics.context_loss.get_all().items():
        labels_dict = dict(labels)
        loss_counter.labels(**labels_dict).inc(value)

    # P2-7: Self-observability counters
    _SELF_OBS_COUNTERS = [
        ("classification_fast_total", classification_fast_total),
        ("classification_llm_total", classification_llm_total),
        ("classification_fast_miss_total", classification_fast_miss_total),
        ("export_success_total", export_success_total),
        ("export_failure_total", export_failure_total),
        ("dedup_suppressed_total", dedup_suppressed_total),
    ]
    for _name, _counter in _SELF_OBS_COUNTERS:
        prom = PromCounter(
            f"risicare_{_name}",
            _counter.description,
            registry=registry,
        )
        total = sum(_counter.get_all().values())
        if total:
            prom.inc(total)

    return generate_latest(registry).decode("utf-8")


# =============================================================================
# Public API
# =============================================================================

__all__ = [
    # Configuration
    "enable_context_observability",
    "disable_context_observability",
    "is_observability_enabled",
    "ObservabilityConfig",
    # Types
    "ContextType",
    "ContextOperation",
    # Metrics
    "context_metrics",
    "ContextMetrics",
    # Self-observability counters (P2-7)
    "Counter",
    "classification_fast_total",
    "classification_llm_total",
    "classification_fast_miss_total",
    "export_success_total",
    "export_failure_total",
    "dedup_suppressed_total",
    # Logging
    "log_context_enter",
    "log_context_exit",
    "log_context_loss",
    # Scope tracking
    "enter_traced_scope",
    "exit_traced_scope",
    "is_inside_traced_scope",
    # Reporting
    "get_context_health_report",
    "get_prometheus_metrics",
]
