"""
blackbox-recorder - A zero-configuration execution recorder for Python.

Records function execution including call counts, timing, exceptions,
return values, arguments, and call timelines. Uses sys.settrace() to
hook into execution without requiring code changes.

Public API:
    start()        - Begin recording execution
    stop()         - Stop recording
    report()       - Display aggregated execution summary
    timeline()     - Display chronological call timeline (v0.2)
    save_report()  - Save report to file
    save_json()    - Export execution data as JSON (v0.2)
    configure()    - Set options like filters, sampling (v0.3)
    Recorder       - Context manager for automatic start/stop

CLI (v0.3):
    blackbox-recorder run script.py
    blackbox-recorder run script.py --json output.json
    blackbox-recorder run script.py --include mymodule --exclude tests

Example:
    >>> import blackbox_recorder
    >>> blackbox_recorder.start()
    >>> # your code here
    >>> blackbox_recorder.stop()
    >>> blackbox_recorder.report()
"""

from __future__ import annotations

import json
import os
import random
import sys
import time
from datetime import datetime
from types import FrameType, TracebackType
from typing import Any, Callable, Dict, List, Optional, Set, Type, cast

# Version
__version__ = "0.4.0"

# Public API
__all__ = [
    "start",
    "stop",
    "report",
    "timeline",
    "save_report",
    "save_json",
    "configure",
    "get_async_tracer",
    "Recorder",
]

# Configuration constants (defaults)
_REPORT_WIDTH = 70
_FUNCTION_NAME_WIDTH = 20
_TIMELINE_WIDTH = 80
_IGNORE_PATTERNS = (
    "/lib/python",
    "/site-packages/",
    "<frozen",
    "importlib",
)
_SELF_MODULE = "blackboxpy"

# Shallow repr limits
_MAX_REPR_LEN = 80
_MAX_ARGS = 5
_MAX_COLLECTION_ITEMS = 3

# Type alias for sys.settrace callbacks
_TraceFunc = Optional[Callable[[FrameType, str, Any], Any]]


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _format_exception(
    exc_type: Type[BaseException],
    exc_value: Optional[BaseException],
) -> str:
    """Format an exception as 'TypeName' or 'TypeName('message')'."""
    if exc_value is not None:
        return f"{exc_type.__name__}('{exc_value}')"
    return exc_type.__name__


def _matches_module(module: str, prefix: str) -> bool:
    """Check if a module name matches a prefix (exact or sub-module)."""
    return module == prefix or module.startswith(prefix + ".")


# ---------------------------------------------------------------------------
# Safe repr for argument/return value capture (v0.2)
# ---------------------------------------------------------------------------

def _safe_repr(obj: Any, max_len: int = _MAX_REPR_LEN) -> str:
    """
    Create a safe, truncated string representation of an object.

    Handles objects that raise exceptions during repr, and truncates
    long representations to prevent memory issues.

    Args:
        obj: Object to represent
        max_len: Maximum length of the output string

    Returns:
        A safe string representation, truncated if needed
    """
    if obj is None:
        return "None"
    if isinstance(obj, (int, float, bool)):
        return repr(obj)
    if isinstance(obj, (str, bytes)):
        r = repr(obj)
        return r[:max_len - 4] + "...'" if len(r) > max_len else r
    if isinstance(obj, (list, tuple)):
        brackets = "[]" if isinstance(obj, list) else "()"
        items: List[str] = []
        seq: Any = cast(Any, obj)
        for i, item in enumerate(seq):
            if i >= _MAX_COLLECTION_ITEMS:
                items.append(f"...+{len(seq) - i}")
                break
            items.append(_safe_repr(item, max_len=30))
        return f"{brackets[0]}{', '.join(items)}{brackets[1]}"
    if isinstance(obj, dict):
        pairs: List[str] = []
        mapping: Any = cast(Any, obj)
        for i, (k, v) in enumerate(mapping.items()):
            if i >= _MAX_COLLECTION_ITEMS:
                pairs.append(f"...+{len(mapping) - i}")
                break
            pairs.append(f"{_safe_repr(k, 20)}: {_safe_repr(v, 20)}")
        return "{" + ", ".join(pairs) + "}"
    if isinstance(obj, set):
        elems: List[str] = []
        s: Any = cast(Any, obj)
        for i, item in enumerate(s):
            if i >= _MAX_COLLECTION_ITEMS:
                elems.append(f"...+{len(s) - i}")
                break
            elems.append(_safe_repr(item, 20))
        return "{" + ", ".join(elems) + "}"
    # Fallback for other types — repr() may raise on custom objects
    try:
        r = repr(obj)
        return f"<{type(obj).__name__}>" if len(r) > max_len else r
    except Exception:
        return f"<{type(obj).__name__}>"


# ---------------------------------------------------------------------------
# Configuration (v0.3)
# ---------------------------------------------------------------------------

class Config:
    """
    Configuration for the tracer.

    Controls filtering, sampling, argument capture, and persistence.
    All fields have sensible defaults matching v0.1 behavior.
    """

    __slots__ = (
        "include_modules",
        "exclude_modules",
        "capture_args",
        "capture_returns",
        "sampling_rate",
        "persistence_path",
    )

    def __init__(self) -> None:
        self.include_modules: Optional[Set[str]] = None  # None = include all
        self.exclude_modules: Optional[Set[str]] = None  # None = exclude none
        self.capture_args: bool = True       # v0.2: capture arguments
        self.capture_returns: bool = True    # v0.2: capture return values
        self.sampling_rate: float = 1.0      # v0.4: 1.0 = trace everything
        self.persistence_path: Optional[str] = None  # v0.4: auto-save path

    def reset(self) -> None:
        """Reset to default configuration."""
        self.include_modules = None
        self.exclude_modules = None
        self.capture_args = True
        self.capture_returns = True
        self.sampling_rate = 1.0
        self.persistence_path = None

    def to_dict(self) -> Dict[str, Any]:
        """Serialize configuration to a dictionary."""
        return {
            "include_modules": sorted(self.include_modules) if self.include_modules else None,
            "exclude_modules": sorted(self.exclude_modules) if self.exclude_modules else None,
            "capture_args": self.capture_args,
            "capture_returns": self.capture_returns,
            "sampling_rate": self.sampling_rate,
            "persistence_path": self.persistence_path,
        }


# Global configuration
_config = Config()


# ---------------------------------------------------------------------------
# Timeline Event (v0.2)
# ---------------------------------------------------------------------------

class TimelineEvent:
    """
    A single event in the execution timeline.

    Captures the exact moment a function was called, returned, or threw
    an exception, along with captured arguments and return values.
    """

    __slots__ = (
        "timestamp",
        "event_type",
        "func_name",
        "module",
        "args_repr",
        "return_repr",
        "exception_repr",
    )

    def __init__(
        self,
        timestamp: float,
        event_type: str,
        func_name: str,
        module: str,
    ) -> None:
        self.timestamp: float = timestamp
        self.event_type: str = event_type  # "call", "return", "exception"
        self.func_name: str = func_name
        self.module: str = module
        self.args_repr: Optional[str] = None
        self.return_repr: Optional[str] = None
        self.exception_repr: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        """Serialize event to a dictionary for JSON export."""
        d: Dict[str, Any] = {
            "timestamp": self.timestamp,
            "event": self.event_type,
            "function": self.func_name,
            "module": self.module,
        }
        if self.args_repr is not None:
            d["args"] = self.args_repr
        if self.return_repr is not None:
            d["return_value"] = self.return_repr
        if self.exception_repr is not None:
            d["exception"] = self.exception_repr
        return d


# ---------------------------------------------------------------------------
# ExecutionData
# ---------------------------------------------------------------------------

class ExecutionData:
    """
    Stores aggregated execution data for a single function.

    Tracks function calls, timing, exceptions, return values,
    and optionally captured arguments and return values (v0.2).
    """

    __slots__ = (
        "func_name",
        "module",
        "call_count",
        "total_time",
        "exceptions",
        "none_returns",
        "_active_calls",
        "args_samples",
        "return_samples",
    )

    def __init__(self, func_name: str, module: str) -> None:
        self.func_name: str = func_name
        self.module: str = module
        self.call_count: int = 0
        self.total_time: float = 0.0
        self.exceptions: List[str] = []
        self.none_returns: int = 0
        self._active_calls: List[float] = []
        self.args_samples: List[str] = []     # v0.2: shallow arg snapshots
        self.return_samples: List[str] = []   # v0.2: shallow return snapshots

    def record_call(self, args_repr: Optional[str] = None) -> None:
        """Record a function call with optional argument snapshot."""
        self.call_count += 1
        self._active_calls.append(time.perf_counter())
        if args_repr is not None:
            self.args_samples.append(args_repr)

    def record_return(self, return_value: Any, return_repr: Optional[str] = None) -> None:
        """Record a function return with optional return value snapshot."""
        if self._active_calls:
            start_time = self._active_calls.pop()
            elapsed = time.perf_counter() - start_time
            self.total_time += elapsed

        if return_value is None:
            self.none_returns += 1

        if return_repr is not None:
            self.return_samples.append(return_repr)

    def record_exception(
        self,
        exc_type: Type[BaseException],
        exc_value: Optional[BaseException],
    ) -> None:
        """Record an exception."""
        if self._active_calls:
            start_time = self._active_calls.pop()
            elapsed = time.perf_counter() - start_time
            self.total_time += elapsed

        self.exceptions.append(_format_exception(exc_type, exc_value))

    @property
    def avg_time_ms(self) -> float:
        """Average execution time in milliseconds."""
        if self.call_count == 0:
            return 0.0
        return (self.total_time / self.call_count) * 1000.0

    def to_dict(self) -> Dict[str, Any]:
        """Serialize to dictionary for JSON export (v0.2)."""
        d: Dict[str, Any] = {
            "function": self.func_name,
            "module": self.module,
            "call_count": self.call_count,
            "total_time_ms": round(self.total_time * 1000, 3),
            "avg_time_ms": round(self.avg_time_ms, 3),
            "none_returns": self.none_returns,
            "exceptions": self.exceptions,
        }
        if self.args_samples:
            d["args_samples"] = self.args_samples
        if self.return_samples:
            d["return_samples"] = self.return_samples
        return d


# ---------------------------------------------------------------------------
# Tracer
# ---------------------------------------------------------------------------

class Tracer:
    """
    Core tracing engine using sys.settrace.

    Supports:
    - Aggregated execution summary (v0.1)
    - Chronological timeline with arg/return capture (v0.2)
    - Module include/exclude filters (v0.3)
    - Sampling mode for reduced overhead (v0.4)
    - Disk persistence for long-running processes (v0.4)

    Thread Safety:
        sys.settrace() is per-thread. This tracer tracks the main thread.
        For async code, use the AsyncTracer wrapper (v0.4).
    """

    __slots__ = (
        "_enabled",
        "_data",
        "_timeline",
        "_previous_trace",
        "_start_wall",
        "_config",
    )

    def __init__(self, config: Optional[Config] = None) -> None:
        self._enabled: bool = False
        self._data: Dict[str, ExecutionData] = {}
        self._timeline: List[TimelineEvent] = []
        self._previous_trace: _TraceFunc = None
        self._start_wall: float = 0.0
        self._config: Config = config or _config

    def _should_ignore(self, frame: FrameType) -> bool:
        """Determine if a frame should be ignored."""
        filename = frame.f_code.co_filename
        module = frame.f_globals.get("__name__", "")

        # Always ignore stdlib / site-packages / frozen / importlib
        if any(pattern in filename for pattern in _IGNORE_PATTERNS):
            return True

        # Always ignore our own module
        if _matches_module(module, _SELF_MODULE):
            return True

        # v0.3: Module filters
        cfg = self._config

        # Include filter: if set, only trace matching modules
        if cfg.include_modules is not None:
            if not any(_matches_module(module, inc) for inc in cfg.include_modules):
                return True

        # Exclude filter: skip matching modules
        if cfg.exclude_modules is not None:
            if any(_matches_module(module, exc) for exc in cfg.exclude_modules):
                return True

        return False

    def _capture_args(self, frame: FrameType) -> Optional[str]:
        """Capture shallow representation of function arguments (v0.2)."""
        if not self._config.capture_args:
            return None
        try:
            code = frame.f_code
            # Get argument names from the code object
            arg_count = code.co_argcount
            varnames = code.co_varnames[:arg_count]
            local_vars = frame.f_locals

            parts: List[str] = []
            for i, name in enumerate(varnames):
                if i >= _MAX_ARGS:
                    parts.append(f"...+{arg_count - i}")
                    break
                if name == "self":
                    # Show class name instead of full repr
                    self_obj = local_vars.get(name)
                    if self_obj is not None:
                        parts.append(f"self=<{type(self_obj).__name__}>")
                    continue
                val = local_vars.get(name)
                parts.append(f"{name}={_safe_repr(val)}")
            return ", ".join(parts) if parts else None
        except Exception:
            return None

    def _trace_callback(
        self,
        frame: FrameType,
        event: str,
        arg: Any,
    ) -> _TraceFunc:
        """Trace callback invoked by sys.settrace."""
        if not self._enabled:
            return None

        if self._should_ignore(frame):
            return self._trace_callback

        # v0.4: Sampling — randomly skip events based on sampling_rate
        if self._config.sampling_rate < 1.0:
            if random.random() > self._config.sampling_rate:
                return self._trace_callback

        # Get or create execution data
        func_name = frame.f_code.co_name
        module = frame.f_globals.get("__name__", "<unknown>")
        key = f"{module}.{func_name}"

        if key not in self._data:
            self._data[key] = ExecutionData(func_name, module)

        exec_data = self._data[key]
        now = time.perf_counter()
        relative_time = now - self._start_wall

        # Handle events
        if event == "call":
            args_repr = self._capture_args(frame)
            exec_data.record_call(args_repr)

            # Timeline event
            te = TimelineEvent(
                relative_time,
                "call",
                exec_data.func_name,
                exec_data.module,
            )
            te.args_repr = args_repr
            self._timeline.append(te)

        elif event == "return":
            return_repr = None
            if self._config.capture_returns:
                return_repr = _safe_repr(arg)
            exec_data.record_return(arg, return_repr)

            te = TimelineEvent(
                relative_time,
                "return",
                exec_data.func_name,
                exec_data.module,
            )
            te.return_repr = return_repr
            self._timeline.append(te)

        elif event == "exception":
            exc_type, exc_value, _ = arg
            exec_data.record_exception(exc_type, exc_value)

            te = TimelineEvent(
                relative_time,
                "exception",
                exec_data.func_name,
                exec_data.module,
            )
            te.exception_repr = _format_exception(exc_type, exc_value)
            self._timeline.append(te)

        return self._trace_callback

    # ---- Lifecycle ----

    def start(self) -> None:
        """Start tracing. Clears previous data."""
        if self._enabled:
            return
        self._enabled = True
        self._data.clear()
        self._timeline.clear()
        self._start_wall = time.perf_counter()
        self._previous_trace = sys.gettrace()
        sys.settrace(self._trace_callback)

    def stop(self) -> None:
        """Stop tracing and optionally persist to disk (v0.4)."""
        if not self._enabled:
            return
        self._enabled = False
        sys.settrace(self._previous_trace)
        self._previous_trace = None

        # v0.4: Auto-persist if configured
        if self._config.persistence_path:
            self._persist_to_disk()

    def is_enabled(self) -> bool:
        """Check if tracing is active."""
        return self._enabled

    # ---- Public accessors (used by AsyncTracer) ----

    @property
    def data(self) -> Dict[str, ExecutionData]:
        """Collected execution data, keyed by module.function."""
        return self._data

    @property
    def timeline_events(self) -> List[TimelineEvent]:
        """Chronological list of recorded timeline events."""
        return self._timeline

    @property
    def tracer_config(self) -> Config:
        """Active configuration for this tracer."""
        return self._config

    @property
    def start_wall_time(self) -> float:
        """Perf counter value when tracing started."""
        return self._start_wall

    # ---- Report generation (v0.1) ----

    def generate_report(self) -> str:
        """Generate human-readable execution summary."""
        if not self._data:
            return "No execution data recorded."

        lines: List[str] = []
        lines.append("Execution Summary")
        lines.append("-" * _REPORT_WIDTH)

        sorted_data = sorted(
            self._data.items(),
            key=lambda item: item[1].call_count,
            reverse=True,
        )

        for _, exec_data in sorted_data:
            parts = [f"{exec_data.func_name:<{_FUNCTION_NAME_WIDTH}}"]
            parts.append(f"| called {exec_data.call_count}x")
            parts.append(f"| avg {exec_data.avg_time_ms:.1f}ms")

            if exec_data.exceptions:
                parts.append(f"| {len(exec_data.exceptions)} exception(s)")
            elif exec_data.none_returns > 0:
                parts.append(f"| returned None {exec_data.none_returns}x")
            else:
                parts.append("| ok")

            lines.append(" ".join(parts))

        # Exception details
        exceptions_found = [
            (key, data) for key, data in sorted_data if data.exceptions
        ]
        if exceptions_found:
            lines.append("")
            lines.append("Exceptions")
            lines.append("-" * _REPORT_WIDTH)
            for _, exec_data in exceptions_found:
                for exc in exec_data.exceptions:
                    lines.append(f"{exec_data.func_name} → {exc}")

        return "\n".join(lines)

    # ---- Timeline report (v0.2) ----

    def generate_timeline(self) -> str:
        """
        Generate a chronological timeline of execution events.

        Shows each function call, return, exception in order with
        timestamps, captured arguments, and return values.
        """
        if not self._timeline:
            return "No timeline data recorded."

        lines: List[str] = []
        lines.append("Execution Timeline")
        lines.append("-" * _TIMELINE_WIDTH)

        for event in self._timeline:
            ts = f"{event.timestamp * 1000:>10.3f}ms"

            if event.event_type == "call":
                arrow = ">>>"
                detail = f"{event.func_name}()"
                if event.args_repr:
                    detail = f"{event.func_name}({event.args_repr})"
            elif event.event_type == "return":
                arrow = "<<<"
                detail = f"{event.func_name}"
                if event.return_repr:
                    detail += f" -> {event.return_repr}"
            else:  # exception
                arrow = "!!!"
                detail = f"{event.func_name}"
                if event.exception_repr:
                    detail += f" raised {event.exception_repr}"

            line = f"{ts} {arrow} {detail}"
            lines.append(line)

        lines.append("-" * _TIMELINE_WIDTH)
        lines.append(f"Total events: {len(self._timeline)}")
        return "\n".join(lines)

    # ---- JSON export (v0.2) ----

    def export_json(self) -> Dict[str, Any]:
        """
        Export all execution data as a JSON-serializable dictionary.

        Includes summary data, timeline, and configuration.
        """
        sorted_data = sorted(
            self._data.items(),
            key=lambda item: item[1].call_count,
            reverse=True,
        )

        return {
            "version": __version__,
            "timestamp": datetime.now().isoformat(),
            "config": self._config.to_dict(),
            "summary": [data.to_dict() for _, data in sorted_data],
            "timeline": [event.to_dict() for event in self._timeline],
        }

    # ---- Disk persistence (v0.4) ----

    def _persist_to_disk(self) -> None:
        """Auto-save execution data to disk as JSON."""
        path = self._config.persistence_path
        if not path:
            return
        try:
            dir_path = os.path.dirname(path)
            if dir_path:
                os.makedirs(dir_path, exist_ok=True)
            data = self.export_json()
            with open(path, "w") as f:
                json.dump(data, f, indent=2)
        except (IOError, OSError):
            pass  # Silent failure for persistence — must not crash user code


# ---------------------------------------------------------------------------
# Async Tracer (v0.4)
# ---------------------------------------------------------------------------

class AsyncTracer:
    """
    Async-aware tracing wrapper.

    Wraps coroutines to trace async function execution. Since
    sys.settrace doesn't naturally track await boundaries, this
    uses wrapper coroutines to capture call/return/exception
    events for async functions.

    Usage:
        tracer = AsyncTracer()
        result = await tracer.trace(my_coroutine(args))
    """

    def __init__(self, tracer: Optional[Tracer] = None) -> None:
        self._tracer: Tracer = tracer or _tracer
        self._data: Dict[str, ExecutionData] = self._tracer.data
        self._timeline: List[TimelineEvent] = self._tracer.timeline_events
        self._config: Config = self._tracer.tracer_config

    async def trace(self, coro: Any) -> Any:
        """
        Trace an awaitable coroutine.

        Captures call, return, and exception events for the async function.

        Args:
            coro: A coroutine object to trace

        Returns:
            The result of the coroutine
        """
        func_name = getattr(coro, "__qualname__", None) or getattr(coro, "__name__", "<coroutine>")
        module = getattr(coro, "__module__", "<unknown>") or "<unknown>"
        key = f"{module}.{func_name}"

        # Apply module filters
        if self._config.include_modules is not None:
            if not any(_matches_module(module, inc) for inc in self._config.include_modules):
                return await coro

        if self._config.exclude_modules is not None:
            if any(_matches_module(module, exc) for exc in self._config.exclude_modules):
                return await coro

        # Create execution data if needed
        if key not in self._data:
            self._data[key] = ExecutionData(func_name, module)

        exec_data = self._data[key]
        start_wall = self._tracer.start_wall_time or time.perf_counter()
        now = time.perf_counter()
        relative_time = now - start_wall

        # Record call
        exec_data.record_call()
        te_call = TimelineEvent(relative_time, "call", func_name, module)
        self._timeline.append(te_call)

        try:
            result = await coro
            # Record return
            return_repr = None
            if self._config.capture_returns:
                return_repr = _safe_repr(result)
            exec_data.record_return(result, return_repr)

            now2 = time.perf_counter()
            te_ret = TimelineEvent(now2 - start_wall, "return", func_name, module)
            te_ret.return_repr = return_repr
            self._timeline.append(te_ret)

            return result
        except Exception as e:
            exc_type = type(e)
            exec_data.record_exception(exc_type, e)

            now2 = time.perf_counter()
            te_exc = TimelineEvent(now2 - start_wall, "exception", func_name, module)
            te_exc.exception_repr = _format_exception(exc_type, e)
            self._timeline.append(te_exc)

            raise


# ---------------------------------------------------------------------------
# Global singleton
# ---------------------------------------------------------------------------

_tracer = Tracer()


class Recorder:
    """
    Context manager for automatic start/stop of execution recording.

    Example:
        >>> with Recorder():
        ...     my_function()
        >>> report()
    """

    def __enter__(self) -> Recorder:
        _tracer.start()
        return self

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> bool:
        _tracer.stop()
        return False


# ---------------------------------------------------------------------------
# Public API functions
# ---------------------------------------------------------------------------

def start() -> None:
    """Start recording execution."""
    _tracer.start()


def stop() -> None:
    """Stop recording execution."""
    _tracer.stop()


def report() -> None:
    """Print execution summary report to stdout."""
    print(_tracer.generate_report())


def timeline() -> None:
    """
    Print chronological execution timeline to stdout (v0.2).

    Shows every call, return, and exception in order with timestamps
    and captured arguments/return values.
    """
    print(_tracer.generate_timeline())


def save_report(filepath: str, mode: str = "w") -> None:
    """
    Save execution report to a file.

    Args:
        filepath: Path to save report
        mode: "w" to overwrite (default), "a" to append
    """
    if mode not in ("w", "a"):
        raise ValueError("mode must be 'w' (write) or 'a' (append)")

    report_content = _tracer.generate_report()

    try:
        dir_path = os.path.dirname(filepath)
        if dir_path:
            os.makedirs(dir_path, exist_ok=True)
        with open(filepath, mode) as f:
            if mode == "a":
                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                f.write(f"\n{'=' * 70}\n")
                f.write(f"Blackbox Report - {timestamp}\n")
                f.write(f"{'=' * 70}\n")

            f.write(report_content)

            if mode == "a":
                f.write(f"\n{'=' * 70}\n")
    except IOError as e:
        raise IOError(f"Failed to write report to {filepath}: {e}")


def save_json(filepath: str) -> None:
    """
    Export execution data as JSON (v0.2).

    Saves a complete JSON file with summary, timeline, and config.

    Args:
        filepath: Path for JSON output file
    """
    data = _tracer.export_json()
    dir_path = os.path.dirname(filepath)
    if dir_path:
        os.makedirs(dir_path, exist_ok=True)
    with open(filepath, "w") as f:
        json.dump(data, f, indent=2)


def configure(**kwargs: Any) -> None:
    """
    Configure blackboxpy options (v0.3).

    Keyword Args:
        include: str or list of module name prefixes to include
        exclude: str or list of module name prefixes to exclude
        capture_args: bool - capture function arguments (default: True)
        capture_returns: bool - capture return values (default: True)
        sampling_rate: float - 0.0 to 1.0, fraction of calls to trace (default: 1.0)
        persistence_path: str or None - auto-save JSON path on stop (default: None)
        reset: bool - reset to defaults before applying (default: False)

    Example:
        >>> blackboxpy.configure(include="myapp", exclude=["tests", "fixtures"])
        >>> blackboxpy.configure(sampling_rate=0.5)  # trace 50% of calls
        >>> blackboxpy.configure(persistence_path="logs/trace.json")
        >>> blackboxpy.configure(reset=True)  # back to defaults
    """
    cfg = _config

    if kwargs.get("reset", False):
        cfg.reset()
        return

    if "include" in kwargs:
        inc = kwargs["include"]
        if isinstance(inc, str):
            cfg.include_modules = {inc}
        elif inc is None:
            cfg.include_modules = None
        else:
            cfg.include_modules = set(inc)

    if "exclude" in kwargs:
        exc = kwargs["exclude"]
        if isinstance(exc, str):
            cfg.exclude_modules = {exc}
        elif exc is None:
            cfg.exclude_modules = None
        else:
            cfg.exclude_modules = set(exc)

    if "capture_args" in kwargs:
        cfg.capture_args = bool(kwargs["capture_args"])

    if "capture_returns" in kwargs:
        cfg.capture_returns = bool(kwargs["capture_returns"])

    if "sampling_rate" in kwargs:
        rate = float(kwargs["sampling_rate"])
        if not 0.0 <= rate <= 1.0:
            raise ValueError("sampling_rate must be between 0.0 and 1.0")
        cfg.sampling_rate = rate

    if "persistence_path" in kwargs:
        cfg.persistence_path = kwargs["persistence_path"]


def get_async_tracer() -> AsyncTracer:
    """
    Get an async tracer instance (v0.4).

    Returns an AsyncTracer that can wrap coroutines for tracing.

    Example:
        >>> at = blackboxpy.get_async_tracer()
        >>> result = await at.trace(my_async_function())
    """
    return AsyncTracer(_tracer)


# ---------------------------------------------------------------------------
# CLI entry point (v0.3)
# ---------------------------------------------------------------------------

def _cli_main() -> None:
    """
    CLI entry point: blackboxpy run script.py [options]

    Usage:
        blackboxpy run script.py
        blackboxpy run script.py --json output.json
        blackboxpy run script.py --report output.txt
        blackboxpy run script.py --include mymodule
        blackboxpy run script.py --exclude tests
        blackboxpy run script.py --timeline
        blackboxpy run script.py --sampling 0.5
        blackboxpy run script.py --persist trace.json
    """
    import argparse

    parser = argparse.ArgumentParser(
        prog="blackboxpy",
        description="blackboxpy - Zero-configuration execution recorder for Python",
    )
    sub = parser.add_subparsers(dest="command")

    run_parser = sub.add_parser("run", help="Run a Python script with execution recording")
    run_parser.add_argument("script", help="Python script to execute")
    run_parser.add_argument("script_args", nargs="*", help="Arguments to pass to the script")
    run_parser.add_argument("--json", metavar="FILE", help="Export execution data as JSON")
    run_parser.add_argument("--report", metavar="FILE", help="Save report to file")
    run_parser.add_argument("--timeline", action="store_true", help="Show timeline report")
    run_parser.add_argument("--include", nargs="+", metavar="MOD", help="Only trace these modules")
    run_parser.add_argument("--exclude", nargs="+", metavar="MOD", help="Exclude these modules from tracing")
    run_parser.add_argument("--sampling", type=float, metavar="RATE", help="Sampling rate 0.0-1.0")
    run_parser.add_argument("--persist", metavar="FILE", help="Auto-persist trace data to JSON")
    run_parser.add_argument("--no-args", action="store_true", help="Disable argument capture")
    run_parser.add_argument("--no-returns", action="store_true", help="Disable return value capture")

    sub.add_parser("version", help="Show version")

    args = parser.parse_args()

    if args.command == "version":
        print(f"blackboxpy {__version__}")
        return

    if args.command != "run":
        parser.print_help()
        return

    # Apply configuration from CLI flags
    cli_opts: Dict[str, Any] = {}
    if args.include:
        cli_opts["include"] = args.include
    if args.exclude:
        cli_opts["exclude"] = args.exclude
    if args.sampling is not None:
        cli_opts["sampling_rate"] = args.sampling
    if args.persist:
        cli_opts["persistence_path"] = args.persist
    if args.no_args:
        cli_opts["capture_args"] = False
    if args.no_returns:
        cli_opts["capture_returns"] = False
    if cli_opts:
        configure(**cli_opts)

    # Execute the script
    script_path = os.path.abspath(args.script)
    if not os.path.isfile(script_path):
        print(f"Error: Script not found: {script_path}", file=sys.stderr)
        sys.exit(1)

    # Set up sys.argv for the script
    sys.argv = [script_path] + args.script_args

    # Read and execute
    with open(script_path) as f:
        code = f.read()

    script_globals: Dict[str, Any] = {
        "__name__": "__main__",
        "__file__": script_path,
        "__builtins__": __builtins__,
    }

    start()
    try:
        exec(compile(code, script_path, "exec"), script_globals)
    except SystemExit:
        pass
    except Exception as e:
        print(f"\nScript raised: {type(e).__name__}: {e}", file=sys.stderr)
    finally:
        stop()

    # Output
    print()
    report()

    if args.timeline:
        print()
        timeline()

    if args.report:
        save_report(args.report)
        print(f"\nReport saved to {args.report}")

    if args.json:
        save_json(args.json)
        print(f"\nJSON exported to {args.json}")


if __name__ == "__main__":
    _cli_main()
