"""Sentry crash + error reporting for the Mac client.

Without this, an exception in a shipped .app prints to a console that no
user ever opens — we find out about bugs only when a customer thinks to
write a support email, which usually never happens. With this, every
uncaught exception (and every error we explicitly capture) shows up in
Sentry within seconds, including a deduplicated trace and macOS version.

PRIVACY GUARDRAILS
------------------
This app processes user voice transcripts. Sentry must NOT receive any
of that content even if an exception happens deep inside the pipeline:

  - `send_default_pii=False`   — Sentry won't auto-collect usernames,
                                 IP addresses, or local variable values
                                 from stack frames.
  - `before_send` scrubber     — runs on every event before transmission;
                                 walks the exception chain and clears any
                                 string that looks like a transcript (long
                                 free-form text). Belt-and-suspenders.
  - No `attach_stacktrace=True` on info logs — we only send actual errors,
                                 not breadcrumbs of normal operation.

ENABLING
--------
Set LUMO_SENTRY_DSN in the environment or bake it into _DEFAULT_DSN below
when preparing a release. DSN is a "public client identifier" per Sentry's
threat model — safe to ship in the .app bundle. If empty/None, this module
is a no-op (init returns silently and capture() does nothing).
"""

from __future__ import annotations

import os
from typing import Any, Optional

# Bake a DSN here for shipped builds. Empty string = Sentry disabled.
# Get yours at sentry.io → Projects → New Project (Python) → DSN.
# Lirelle project on the EU region (anstispirit@gmail.com), created 2026-05-30.
_DEFAULT_DSN = "https://f9bceb5e99e93a2b0a16faa0442762a7@o4511480577196032.ingest.de.sentry.io/4511480581128272"

# Truncate any captured string longer than this — protects against
# accidentally shipping a transcript inside an error message.
_MAX_STRING_LEN = 200

_initialised = False


def _looks_like_transcript(s: str) -> bool:
    """Heuristic: long free-form text is probably a user transcript and
    must not leave the device. Pure-stack-trace strings tend to be short,
    contain ':' / '/' / '<' from Python tracebacks, and never approach
    _MAX_STRING_LEN."""
    if len(s) < _MAX_STRING_LEN:
        return False
    # If it has the shape of a Python repr (lots of '<', '>', "object at 0x...")
    # it's probably stack data, not user text — let it through (truncated).
    if "object at 0x" in s or s.startswith("<"):
        return False
    return True


def _scrub(value: Any) -> Any:
    """Recursively scrub anything that looks like user transcript text.
    Replaces with `[scrubbed]` placeholder so the event shape stays valid."""
    if isinstance(value, str):
        if _looks_like_transcript(value):
            return "[scrubbed transcript]"
        if len(value) > _MAX_STRING_LEN:
            return value[:_MAX_STRING_LEN] + "…"
        return value
    if isinstance(value, dict):
        return {k: _scrub(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_scrub(v) for v in value]
    if isinstance(value, tuple):
        return tuple(_scrub(v) for v in value)
    return value


def _before_send(event: dict, hint: dict) -> Optional[dict]:  # noqa: ARG001
    """Sentry hook — runs on every event before HTTP send. We scrub
    user-text-shaped strings out of exception messages and frame locals
    (frame locals only present if send_default_pii=True, but defence in
    depth). Returning None drops the event entirely; we always send."""
    try:
        if "exception" in event:
            for exc in event["exception"].get("values", []):
                if "value" in exc:
                    exc["value"] = _scrub(exc["value"])
                stacktrace = exc.get("stacktrace") or {}
                for frame in stacktrace.get("frames", []) or []:
                    if "vars" in frame:
                        frame["vars"] = _scrub(frame["vars"])
        if "message" in event:
            event["message"] = _scrub(event["message"])
    except Exception:  # noqa: BLE001
        # Never let scrubbing failure block the report — Sentry visibility
        # is more important than perfect filtering of an edge case.
        pass
    return event


def init(release: str = "lumo@dev", environment: str = "production") -> None:
    """Initialise Sentry once at app launch. Safe to call multiple times —
    repeated calls are no-ops. If no DSN is configured, this is silent.

    `release` should be the app version (so Sentry can group bugs by build).
    `environment` flags dev vs production for filtering."""
    global _initialised
    if _initialised:
        return
    dsn = os.environ.get("LUMO_SENTRY_DSN", _DEFAULT_DSN).strip()
    if not dsn:
        return
    try:
        import sentry_sdk
    except ImportError:
        print("[telemetry] sentry-sdk not installed — skipping init")
        return
    sentry_sdk.init(
        dsn=dsn,
        release=release,
        environment=environment,
        send_default_pii=False,
        traces_sample_rate=0.0,   # no performance traces — crashes only
        before_send=_before_send,
    )
    _initialised = True
    print(f"[telemetry] sentry initialised  release={release}  env={environment}")


def capture(exc: BaseException, **extra: Any) -> None:
    """Manually report an exception we've already caught. Use this in
    `except` blocks where we recover but want operator visibility. Safe to
    call when Sentry isn't initialised (no-op)."""
    if not _initialised:
        return
    try:
        import sentry_sdk
        with sentry_sdk.push_scope() as scope:
            for k, v in extra.items():
                scope.set_extra(k, _scrub(v))
            sentry_sdk.capture_exception(exc)
    except Exception:  # noqa: BLE001
        pass
