"""Local Variable Capture at Exception Time.

This module captures local variables from exception tracebacks,
similar to Sentry's Exception Replay feature.

Key capabilities:
- Capture local variables from all frames in the traceback
- Safe serialization with size limits
- PII detection and redaction
- Integration with existing probe infrastructure
"""

from __future__ import annotations

import functools
import inspect
import json
import os
import re
import sys
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Set, Type, Union

# PII patterns to detect and redact (extensible via AUTODEBUG_PII_PATTERNS env var)
_DEFAULT_PII_PATTERNS = [
    re.compile(r"password", re.IGNORECASE),
    re.compile(r"passwd", re.IGNORECASE),
    re.compile(r"secret", re.IGNORECASE),
    re.compile(r"api[_-]?key", re.IGNORECASE),
    re.compile(r"token", re.IGNORECASE),
    re.compile(r"\bauth\b", re.IGNORECASE),
    re.compile(r"credential", re.IGNORECASE),
    re.compile(r"private[_-]?key", re.IGNORECASE),
    re.compile(r"access[_-]?key", re.IGNORECASE),
    re.compile(r"ssn", re.IGNORECASE),
    re.compile(r"social[_-]?security", re.IGNORECASE),
    re.compile(r"credit[_-]?card", re.IGNORECASE),
    re.compile(r"card[_-]?number", re.IGNORECASE),
]


def _build_pii_patterns() -> list:
    """Build PII patterns list, extending with AUTODEBUG_PII_PATTERNS env var.

    The env var should contain comma-separated regex patterns, e.g.:
        AUTODEBUG_PII_PATTERNS="db_pwd,account_num,routing_number"
    """
    patterns = list(_DEFAULT_PII_PATTERNS)
    extra = os.environ.get("AUTODEBUG_PII_PATTERNS", "").strip()
    if extra:
        for raw in extra.split(","):
            raw = raw.strip()
            if raw:
                try:
                    patterns.append(re.compile(raw, re.IGNORECASE))
                except re.error:
                    pass
    return patterns


PII_PATTERNS = _build_pii_patterns()

# Types that are safe to serialize
SAFE_TYPES = (
    type(None),
    bool,
    int,
    float,
    str,
    bytes,
    list,
    tuple,
    dict,
    set,
    frozenset,
)

# Maximum sizes
MAX_STRING_LENGTH = 500
MAX_COLLECTION_SIZE = 50
MAX_DEPTH = 4
MAX_FRAME_VARS = 30
MAX_TOTAL_SIZE = 50000  # 50KB


@dataclass
class LocalVariable:
    """A captured local variable."""
    name: str
    value: str
    type_name: str
    is_truncated: bool = False
    is_redacted: bool = False


@dataclass
class FrameLocals:
    """Local variables captured from a single frame."""
    filename: str
    lineno: int
    function: str
    variables: List[LocalVariable]
    code_context: Optional[str] = None


@dataclass
class LocalsSnapshot:
    """Complete snapshot of locals from an exception."""
    exception_type: str
    exception_message: str
    frames: List[FrameLocals]
    captured_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
    total_size: int = 0


def _is_pii_key(key: str) -> bool:
    """Check if a variable name looks like it contains PII."""
    for pattern in PII_PATTERNS:
        if pattern.search(key):
            return True
    return False


def _safe_repr(obj: Any, depth: int = 0) -> str:
    """Safely get a string representation of an object.

    Handles:
    - Circular references
    - Large objects
    - Unserializable objects
    - PII in nested structures
    """
    if depth > MAX_DEPTH:
        return "<max depth exceeded>"

    try:
        # None
        if obj is None:
            return "None"

        # Basic types
        if isinstance(obj, bool):
            return str(obj)
        if isinstance(obj, (int, float)):
            return str(obj)
        if isinstance(obj, bytes):
            if len(obj) > MAX_STRING_LENGTH:
                return f"b'...{len(obj)} bytes...'"
            return repr(obj)
        if isinstance(obj, str):
            if len(obj) > MAX_STRING_LENGTH:
                return repr(obj[:MAX_STRING_LENGTH]) + f"... ({len(obj)} chars)"
            return repr(obj)

        # Collections
        if isinstance(obj, (list, tuple)):
            if len(obj) > MAX_COLLECTION_SIZE:
                items = [_safe_repr(x, depth + 1) for x in obj[:MAX_COLLECTION_SIZE]]
                return f"[{', '.join(items)}, ... ({len(obj)} items)]"
            items = [_safe_repr(x, depth + 1) for x in obj]
            brackets = "[]" if isinstance(obj, list) else "()"
            return f"{brackets[0]}{', '.join(items)}{brackets[1]}"

        if isinstance(obj, set):
            if len(obj) > MAX_COLLECTION_SIZE:
                items = [_safe_repr(x, depth + 1) for x in list(obj)[:MAX_COLLECTION_SIZE]]
                return f"{{{', '.join(items)}, ... ({len(obj)} items)}}"
            items = [_safe_repr(x, depth + 1) for x in obj]
            return f"{{{', '.join(items)}}}"

        if isinstance(obj, dict):
            if len(obj) > MAX_COLLECTION_SIZE:
                items = []
                for i, (k, v) in enumerate(obj.items()):
                    if i >= MAX_COLLECTION_SIZE:
                        break
                    key_repr = _safe_repr(k, depth + 1)
                    if _is_pii_key(str(k)):
                        items.append(f"{key_repr}: <redacted>")
                    else:
                        items.append(f"{key_repr}: {_safe_repr(v, depth + 1)}")
                return f"{{{', '.join(items)}, ... ({len(obj)} items)}}"

            items = []
            for k, v in obj.items():
                key_repr = _safe_repr(k, depth + 1)
                if _is_pii_key(str(k)):
                    items.append(f"{key_repr}: <redacted>")
                else:
                    items.append(f"{key_repr}: {_safe_repr(v, depth + 1)}")
            return f"{{{', '.join(items)}}}"

        # Objects with __dict__
        if hasattr(obj, "__dict__"):
            cls_name = type(obj).__name__
            attrs = {}
            for k, v in list(obj.__dict__.items())[:MAX_COLLECTION_SIZE]:
                if not k.startswith("_"):  # Skip private attrs
                    if _is_pii_key(k):
                        attrs[k] = "<redacted>"
                    else:
                        attrs[k] = _safe_repr(v, depth + 1)
            attr_str = ", ".join(f"{k}={v}" for k, v in attrs.items())
            return f"<{cls_name}({attr_str})>"

        # Fallback to repr with truncation
        result = repr(obj)
        if len(result) > MAX_STRING_LENGTH:
            return result[:MAX_STRING_LENGTH] + "..."
        return result

    except Exception as e:
        return f"<error serializing: {type(e).__name__}>"


def capture_frame_locals(frame: Any, tb_lineno: int) -> FrameLocals:
    """Capture local variables from a single frame.

    Args:
        frame: Frame object from traceback
        tb_lineno: Line number from traceback

    Returns:
        FrameLocals with captured variables
    """
    variables = []

    # Get local variables
    try:
        local_vars = frame.f_locals.copy()
    except Exception:
        local_vars = {}

    # Sort by name and limit
    sorted_vars = sorted(local_vars.items(), key=lambda x: x[0])[:MAX_FRAME_VARS]

    for name, value in sorted_vars:
        # Skip internal names
        if name.startswith("__") and name.endswith("__"):
            continue

        # Check for PII
        is_redacted = _is_pii_key(name)

        if is_redacted:
            value_repr = "<redacted>"
        else:
            value_repr = _safe_repr(value)

        is_truncated = len(value_repr) >= MAX_STRING_LENGTH

        variables.append(LocalVariable(
            name=name,
            value=value_repr,
            type_name=type(value).__name__,
            is_truncated=is_truncated,
            is_redacted=is_redacted,
        ))

    # Get code context
    code_context = None
    try:
        # Try to get the source line
        import linecache
        line = linecache.getline(frame.f_code.co_filename, tb_lineno)
        if line:
            code_context = line.strip()
    except Exception:
        pass

    return FrameLocals(
        filename=frame.f_code.co_filename,
        lineno=tb_lineno,
        function=frame.f_code.co_name,
        variables=variables,
        code_context=code_context,
    )


def capture_exception_locals(
    exc_type: Type[BaseException],
    exc_value: BaseException,
    exc_tb: Any,
    max_frames: int = 10,
) -> LocalsSnapshot:
    """Capture local variables from an exception traceback.

    Args:
        exc_type: Exception type
        exc_value: Exception instance
        exc_tb: Traceback object
        max_frames: Maximum number of frames to capture

    Returns:
        LocalsSnapshot with all captured data
    """
    frames = []
    total_size = 0

    # Walk the traceback
    tb = exc_tb
    frame_count = 0

    while tb is not None and frame_count < max_frames:
        frame = tb.tb_frame
        lineno = tb.tb_lineno

        # Skip internal frames
        filename = frame.f_code.co_filename
        if any(skip in filename for skip in ["/site-packages/", "/dist-packages/", "importlib"]):
            tb = tb.tb_next
            continue

        frame_locals = capture_frame_locals(frame, lineno)
        frames.append(frame_locals)

        # Track size
        for var in frame_locals.variables:
            total_size += len(var.value)

        # Stop if we've exceeded size limit
        if total_size > MAX_TOTAL_SIZE:
            break

        tb = tb.tb_next
        frame_count += 1

    return LocalsSnapshot(
        exception_type=exc_type.__name__,
        exception_message=str(exc_value)[:MAX_STRING_LENGTH],
        frames=frames,
        total_size=total_size,
    )


def snapshot_to_dict(snapshot: LocalsSnapshot) -> Dict[str, Any]:
    """Convert LocalsSnapshot to a JSON-serializable dict."""
    return {
        "exception_type": snapshot.exception_type,
        "exception_message": snapshot.exception_message,
        "captured_at": snapshot.captured_at,
        "total_size": snapshot.total_size,
        "frames": [
            {
                "filename": f.filename,
                "lineno": f.lineno,
                "function": f.function,
                "code_context": f.code_context,
                "variables": [
                    {
                        "name": v.name,
                        "value": v.value,
                        "type": v.type_name,
                        "truncated": v.is_truncated,
                        "redacted": v.is_redacted,
                    }
                    for v in f.variables
                ],
            }
            for f in snapshot.frames
        ],
    }


# Global storage for captured snapshots
_captured_snapshots: List[LocalsSnapshot] = []
_original_excepthook: Optional[Callable] = None


def _exception_hook(
    exc_type: Type[BaseException],
    exc_value: BaseException,
    exc_tb: Any,
) -> None:
    """Custom exception hook that captures locals."""
    global _captured_snapshots

    # Capture locals
    snapshot = capture_exception_locals(exc_type, exc_value, exc_tb)
    _captured_snapshots.append(snapshot)

    # Keep only recent snapshots (memory limit)
    if len(_captured_snapshots) > 100:
        _captured_snapshots = _captured_snapshots[-100:]

    # Call original hook
    if _original_excepthook:
        _original_excepthook(exc_type, exc_value, exc_tb)


def install_locals_capture() -> None:
    """Install the local variable capture exception hook.

    Call this at application startup to enable automatic
    local variable capture for all unhandled exceptions.
    """
    global _original_excepthook

    if _original_excepthook is None:
        _original_excepthook = sys.excepthook
        sys.excepthook = _exception_hook


def uninstall_locals_capture() -> None:
    """Uninstall the local variable capture exception hook."""
    global _original_excepthook

    if _original_excepthook is not None:
        sys.excepthook = _original_excepthook
        _original_excepthook = None


def get_captured_snapshots() -> List[LocalsSnapshot]:
    """Get all captured snapshots."""
    return _captured_snapshots.copy()


def clear_captured_snapshots() -> None:
    """Clear all captured snapshots."""
    global _captured_snapshots
    _captured_snapshots = []


def capture_on_exception(func: Callable) -> Callable:
    """Decorator to capture locals on exception.

    Usage:
        @capture_on_exception
        def my_function():
            ...
    """
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            snapshot = capture_exception_locals(
                type(e),
                e,
                e.__traceback__,
            )
            _captured_snapshots.append(snapshot)
            raise

    return wrapper


# Context manager for capturing locals
class CaptureLocalsContext:
    """Context manager for capturing local variables on exception.

    Usage:
        with CaptureLocalsContext() as ctx:
            # code that might raise
            ...

        if ctx.snapshot:
            print(ctx.snapshot.exception_type)
    """

    def __init__(self):
        self.snapshot: Optional[LocalsSnapshot] = None

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, exc_tb):
        if exc_type is not None:
            self.snapshot = capture_exception_locals(
                exc_type,
                exc_value,
                exc_tb,
            )
        return False  # Don't suppress the exception


# Integration with Hikaflow probe
def integrate_with_probe() -> None:
    """Integrate local variable capture with the Hikaflow probe.

    This adds locals capture to the existing exception patch.
    """
    try:
        from autodebug_probe import recorder
        from autodebug_probe.exception_patch import _original_excepthook as probe_hook

        # The probe already has an exception hook, we augment it
        original_record_exception = getattr(recorder, "record_exception", None)

        if original_record_exception:
            def augmented_record_exception(exc_type, exc_value, exc_tb):
                # Call original
                original_record_exception(exc_type, exc_value, exc_tb)

                # Also capture locals
                snapshot = capture_exception_locals(exc_type, exc_value, exc_tb)
                recorder.record_event("locals_snapshot", snapshot_to_dict(snapshot))

            recorder.record_exception = augmented_record_exception

    except ImportError:
        # Probe not available, install standalone hook
        install_locals_capture()
