"""Local + optional remote crash reporting.

See: ``docs/reference/crash_reporting.md``.

Local reports are JSON files under ``<state>/crashes/``, one per
crash, named ``<timestamp>-<short-uuid>.json``. The directory persists
across runs so a user can attach a single file to a bug report.

Remote shipping is opt-in via the ``<APP>_CRASH_SINK`` environment
variable (where ``<APP>`` is the upper-cased
:func:`mgf.common.app_name`):

  - ``"sentry"``  — uses ``sentry-sdk`` if installed.
  - ``"otlp"``    — uses the OpenTelemetry log exporter if installed.
  - ``"https://..."`` (or ``http://``) — POST JSON to this URL.
  - unset / empty — local only.

The remote dependencies are imported **lazily** so the package works
without the ``observability`` extra installed.

The entry point :func:`report_now` MUST NEVER raise — it is the last
line of defense in every top-level handler. Any failure during report
generation, file write, or remote ship is logged at WARNING via the
module logger and swallowed.

See ``docs/standards/ERROR_HANDLING.md`` §6 for the design.
"""

from __future__ import annotations

import datetime as _dt
import decimal
import json
import logging
import os
import platform
import sys
import traceback
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path, PurePath
from typing import Any

from mgf.common._identity import app_name, app_version
from mgf.common.observability.paths import default_crash_dir

_LOG = logging.getLogger(__name__)

# Sentinel returned when the local write itself fails. Callers
# (e.g., the CLI catch-all) check ``path != _NULL_REPORT_PATH``
# before showing it to the user.
_NULL_REPORT_PATH = Path("/dev/null")


def report_now(exc: BaseException, *, source: str) -> Path:
    """Build, persist locally, and best-effort ship a crash report.

    Returns the local file path so callers can show it to the user.
    Returns :data:`_NULL_REPORT_PATH` if the local write failed (the
    failure is logged; the caller's user-facing message can degrade
    gracefully — see vm-vmanager-app's ``cli/__main__.py`` for the canonical
    pattern).

    SH-01: every payload is run through the bootstrap-wired
    :class:`Redactor` (falling back to :meth:`Redactor.default`) BEFORE
    the local write and BEFORE remote shipping, so a secret in
    ``str(exc)`` / ``exc.args`` / the traceback / the cause chain never
    leaves the process unredacted. If the redactor itself raises
    (custom regex backtrack, malformed payload, etc.), the report is
    replaced with a *minimal* payload (timestamp + id + app + version +
    source + exception type + platform + ``redaction_status="failed"``)
    so the operator still sees the crash happened — but every
    potentially-secret-bearing field is dropped. Fail-safe, not
    fail-open.

    NEVER raises. Any exception during report assembly, file write, or
    remote shipping is caught here and logged at WARNING.
    """
    try:
        report = _build_report(exc, source=source)
    except Exception as build_err:
        _LOG.warning(
            "crash reporter could not build report",
            extra={
                "audit": True,
                "event": "crash.build_failed",
                "error_type": type(build_err).__name__,
                "error_message": str(build_err),
            },
        )
        return _NULL_REPORT_PATH

    # SH-01: redact in place before any egress (local file or
    # remote sink). On redactor failure, the report is REPLACED
    # with a minimal fail-safe payload that carries no
    # potentially-secret fields.
    report = _redact_report(report)

    try:
        path = _write_local(report)
    except Exception as write_err:
        _LOG.warning(
            "crash reporter could not write local report",
            extra={
                "audit": True,
                "event": "crash.write_failed",
                "error_type": type(write_err).__name__,
                "error_message": str(write_err),
            },
        )
        path = _NULL_REPORT_PATH

    # Remote shipping is fire-and-forget. Errors logged, never raised.
    _ship_remote(report)

    return path


def _redact_report(report: dict[str, Any]) -> dict[str, Any]:
    """Scrub secret-shaped values in ``report`` before egress (SH-01).

    Uses the bootstrap-wired :class:`Redactor` when one is active so
    consumer ``extra_keys`` / ``extra_value_patterns`` apply; falls back
    to :meth:`Redactor.default` when no bootstrap context is active
    (e.g., crash during early startup before logging is wired, or under
    a framework-owned ``setup_logging=False`` shape).

    Mutates ``report`` in place and returns it on the happy path. On
    redactor failure, returns a fresh **minimal** dict that preserves
    the operator-visible structure (you can still see a crash happened
    and when) but drops every potentially-secret-bearing field:
    ``exception.message`` / ``exception.args`` / ``traceback`` /
    ``cause``. The minimal report carries ``redaction_status="failed"``
    so the failure is auditable.

    Never raises — preserving the ``report_now`` EH-05 invariant.
    """
    # Lazy imports to avoid an import cycle: observability subsystem
    # imports from itself, and ``_streamer`` lives one level deeper.
    from mgf.common.observability.redaction import Redactor

    try:
        # Prefer the bootstrap-wired redactor so consumer-supplied
        # ``extra_keys`` (e.g., "database_url", "redis_url") and
        # ``extra_value_patterns`` cover the crash path too. Reuses
        # the lookup ``mgf.common.process._streamer`` already does for
        # subprocess output. Falls back to ``Redactor.default()`` when
        # no bootstrap is active — the BUILTIN value-patterns
        # (Bearer / JWT / PEM / OpenAI / GitHub / AWS / Slack / Google)
        # still apply.
        from mgf.common.process._streamer import get_active_redactor

        redactor = get_active_redactor() or Redactor.default()
        redactor.redact(report)
    except Exception as redact_err:
        _LOG.warning(
            "crash reporter redaction failed; emitting minimal payload",
            extra={
                "audit": True,
                "event": "crash.redaction_failed",
                "redaction_error_type": type(redact_err).__name__,
            },
        )
        return _minimal_report(report, error_type=type(redact_err).__name__)
    return report


def _minimal_report(original: dict[str, Any], *, error_type: str) -> dict[str, Any]:
    """Build the SH-01 fail-safe payload when redaction fails.

    Keeps only fields known not to carry consumer secrets:
    ``timestamp``, ``report_id``, ``app``, ``version``, ``source``,
    ``exception.type``, ``platform``. Replaces every dropped field
    with a sentinel so the on-disk JSON shape is consistent with a
    redacted report.

    The redactor's exception TYPE is included for diagnostic value;
    its MESSAGE is deliberately NOT — a redactor that crashed on a
    secret-containing input could surface the secret in its own
    error message.
    """
    sentinel = "[REDACTION FAILED — payload withheld]"
    exc_section = original.get("exception")
    safe_exc_type = (
        exc_section.get("type")
        if isinstance(exc_section, dict) and isinstance(exc_section.get("type"), str)
        else "unknown"
    )
    return {
        "timestamp": original.get("timestamp", ""),
        "report_id": original.get("report_id", ""),
        "app": original.get("app", ""),
        "version": original.get("version", ""),
        "source": original.get("source", ""),
        "exception": {
            "type": safe_exc_type,
            "message": sentinel,
            "args": [sentinel],
        },
        "traceback": [sentinel],
        "cause": None,
        "platform": original.get("platform", {}),
        "redaction_status": "failed",
        "redaction_error_type": error_type,
    }


_SAFE_REPR_TYPES: tuple[type, ...] = (
    str,
    int,
    float,
    complex,
    bool,
    bytes,
    bytearray,
    type(None),
    PurePath,
    uuid.UUID,
    _dt.datetime,
    _dt.date,
    _dt.time,
    _dt.timedelta,
    decimal.Decimal,
)
"""SH-06: types whose ``repr()`` is structurally fixed and known
not to surface arbitrary object state.

Every other type — including stdlib containers (``list``, ``dict``,
``tuple``, ``set``) and consumer-defined classes — falls through to
the ``<TypeName>`` fallback in :func:`_safe_repr`. Containers are
deliberately NOT included: a list whose elements include a custom
class would surface that class's ``__repr__`` through the container's
own delegating repr. The diagnostic-info cost is small (the crash
report still names the type); the leak-surface reduction is large.
"""


def _safe_repr(value: Any) -> str:
    """SH-06: bounded ``repr()`` for exception ``args`` payload.

    The default exception-arg formatting uses ``repr(a)`` so callers
    see ``'/etc/passwd'`` vs ``/etc/passwd`` and so on. But ``repr()``
    of a CUSTOM class can surface arbitrary object state through
    ``__repr__`` (e.g., a connection wrapper that includes
    ``password=...`` in its repr), and the Redactor — which scrubs
    structured payloads + the BUILTIN value-patterns
    (Bearer / JWT / PEM / etc.) — does not pattern-match arbitrary
    ``key=value`` substrings a custom ``__repr__`` might construct.

    Strategy: allowlist the types whose ``__repr__`` is structurally
    fixed (primitives + a few well-known stdlib types). Anything else
    is replaced with ``<TypeName>``. This is the primary defense;
    the Redactor stays the defense-in-depth backstop.
    """
    if isinstance(value, _SAFE_REPR_TYPES):
        return repr(value)
    return f"<{type(value).__name__}>"


def _build_report(exc: BaseException, *, source: str) -> dict[str, Any]:
    """Build the JSON-serialisable report dict.

    Captures: timestamp, app name + version, source label (e.g.,
    ``"cli"``, ``"gui-worker"``, ``"thread:metrics"``), exception
    type / message / args / traceback, the cause chain (one level
    deep — most useful in practice), and platform info.

    The cause chain stops at one level because deeper chains are
    rarely actionable in a crash report and bloat the file.
    """
    cause: dict[str, Any] | None = None
    if exc.__cause__ is not None:
        cause = {
            "type": _qualified_type(exc.__cause__),
            "message": str(exc.__cause__),
        }

    return {
        "timestamp": datetime.now(UTC).isoformat(),
        "report_id": uuid.uuid4().hex,
        "app": app_name(),
        "version": app_version(),
        "source": source,
        "exception": {
            "type": _qualified_type(exc),
            "message": str(exc),
            "args": [_safe_repr(a) for a in getattr(exc, "args", ())],
        },
        "traceback": traceback.format_exception(type(exc), exc, exc.__traceback__),
        "cause": cause,
        "platform": {
            "system": platform.system(),
            "release": platform.release(),
            "python": sys.version,
            "implementation": platform.python_implementation(),
        },
    }


def _write_local(report: dict[str, Any]) -> Path:
    """Atomic write under ``<state>/crashes/`` (rule CF-04 generalised).

    RA-01: uses the shared :func:`mgf.common._io.atomic_write` helper
    so the body is fsync'd before the rename. Parent-directory fsync
    is OFF — losing one crash report on a power event is acceptable
    (the next event produces another report). Vaults and configs
    take the durable-rename path; crash reports don't.
    """
    from mgf.common._io import atomic_write

    target_dir = default_crash_dir()
    target_dir.mkdir(parents=True, exist_ok=True)

    # Replace ``:`` in the timestamp so the filename is portable
    # (Windows forbids ``:`` in filenames).
    safe_ts = report["timestamp"].replace(":", "-")
    short_id = report["report_id"][:8]
    path = target_dir / f"{safe_ts}-{short_id}.json"

    payload = json.dumps(report, indent=2, default=str)
    atomic_write(path, payload, mode=0o600, fsync_dir=False)
    _prune_crash_dir(target_dir)
    return path


# OP-08: the crash dir is owned by this writer, so the writer owns its bound.
_CRASH_REPORTS_KEEP = 100


def _prune_crash_dir(target_dir: Path, keep: int = _CRASH_REPORTS_KEEP) -> None:
    """Keep only the most-recent ``keep`` crash reports (OP-08 — bounded
    state growth). Fail-open: a prune error must never break the crash
    write that just succeeded."""
    import contextlib

    with contextlib.suppress(OSError):
        reports = sorted(
            target_dir.glob("*.json"),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        for stale in reports[keep:]:
            with contextlib.suppress(OSError):
                stale.unlink()


# ---------------------------------------------------------------------------
# Crash-sink registry (closes FEEDBACK API-07)
# ---------------------------------------------------------------------------
#
# Consumers register custom sinks via the ``register_crash_sink``
# decorator. The dispatch in ``_ship_remote`` consults the registry
# instead of the v0.1 hardcoded sentry / otlp / http branches —
# which now exist as PRE-REGISTERED entries (`@register_crash_sink`
# decorations on the existing helpers) so backward compat is
# preserved.
#
# Sink signature:
#
#     CrashSink = Callable[[Path, dict[str, Any]], None]
#
# The sink receives both the local report path AND the in-memory
# payload. Either may be useful; sinks that only need one ignore
# the other.

CrashSink = Callable[[Path, dict[str, Any]], None]
"""Type alias for crash-sink handler callables (closes FEEDBACK API-07)."""

_CRASH_SINKS: dict[str, CrashSink] = {}


def register_crash_sink(name: str) -> Callable[[CrashSink], CrashSink]:
    """Decorator: register a crash-sink handler under ``name``.

    Closes FEEDBACK API-07 — consumers extend the crash-reporter
    sink set without forking ``mgf-common``::

        from pathlib import Path
        from typing import Any

        from mgf.common.observability import register_crash_sink

        @register_crash_sink("my-slack")
        def ship_to_slack(report_path: Path, payload: dict[str, Any]) -> None:
            requests.post(
                SLACK_WEBHOOK,
                json={
                    "text": f"Crash in {payload['app']}: "
                            f"{payload['exception']['message']}",
                },
                timeout=5,
            )

    The sink fires when ``MgfSettings.crash.sinks`` (or the legacy
    ``<APP>_CRASH_SINK`` env var) names it. Sinks MUST NOT raise —
    the crash reporter wraps every sink call in ``try / except`` and
    logs failures at WARNING.

    Threading: ``register_crash_sink`` is single-threaded — call at
    module-import time / bootstrap time, not from a worker. The
    registry itself is read-only on the hot path.
    """

    def decorator(fn: CrashSink) -> CrashSink:
        if name in _CRASH_SINKS:
            raise ValueError(
                f"crash sink {name!r} is already registered "
                f"(unregister first if you intend to replace it)"
            )
        _CRASH_SINKS[name] = fn
        return fn

    return decorator


def unregister_crash_sink(name: str) -> None:
    """Remove a registered crash sink. No-op if ``name`` is unknown.

    Useful for tests + for consumers swapping a sink at runtime.
    """
    _CRASH_SINKS.pop(name, None)


def _registered_crash_sinks() -> dict[str, CrashSink]:
    """Internal: snapshot of the active registry. Used by tests +
    the (future) ``mgf-common doctor`` diagnostic."""
    return dict(_CRASH_SINKS)


def _ship_remote(report: dict[str, Any]) -> None:
    """Best-effort remote shipping. Never raises (caught by ``report_now``).

    Resolution order:

    1. Active :class:`MgfSettings.crash.sinks` — fan-out to every
       named sink (closes FEEDBACK API-07; multi-sink shape).
    2. Legacy ``<APP>_CRASH_SINK`` env var — single sink (v0.1
       behaviour, preserved for ``preset="auto"`` consumers).

    Each sink path catches its own exceptions and logs at WARNING.
    """
    # Path 1: registry-driven (v0.3+).
    sink_names = _resolve_active_sinks()
    if sink_names:
        for name in sink_names:
            if name == "local":
                continue  # local is the always-on baseline
            sink_fn = _CRASH_SINKS.get(name)
            if sink_fn is None:
                _LOG.warning(
                    "crash sink not registered; ignoring",
                    extra={
                        "audit": True,
                        "event": "crash.sink_unknown",
                        "sink_name": name,
                        "hint": "register with @register_crash_sink or remove from MgfSettings.crash.sinks",
                    },
                )
                continue
            try:
                # The local report path is built by `_write_local`
                # before this is called; we don't have it here, so
                # the sink receives the in-memory payload only and
                # an empty Path() sentinel. (Refactor: thread the
                # path through if + when a registered sink needs it.)
                sink_fn(Path(), report)
            except Exception as e:  # never raises (rule EH-10)
                _LOG.warning(
                    "crash sink failed",
                    extra={
                        "audit": True,
                        "event": "crash.sink_failed",
                        "sink_name": name,
                        "error_type": type(e).__name__,
                        "error_message": str(e),
                    },
                )
        return

    # Path 2: v0.1 env-var fallback.
    sink_env = f"{app_name().upper()}_CRASH_SINK"
    sink = os.environ.get(sink_env, "").strip()
    if not sink:
        return
    if sink.startswith(("http://", "https://")):
        # http URLs aren't a registered name — they ARE the URL.
        try:
            _ship_http(report, sink)
        except Exception as e:
            _LOG.warning(
                "crash report shipping failed",
                extra={
                    "audit": True,
                    "event": "crash.ship_failed",
                    "sink_kind": "http",
                    "error_type": type(e).__name__,
                    "error_message": str(e),
                },
            )
        return
    sink_fn = _CRASH_SINKS.get(sink)
    if sink_fn is None:
        _LOG.warning(
            "unknown crash sink",
            extra={
                "audit": True,
                "event": "crash.sink_unknown",
                "sink_name": sink,
                "env_var": sink_env,
                "hint": f"set {sink_env} to a registered name (or unset to disable remote shipping)",
            },
        )
        return
    try:
        sink_fn(Path(), report)
    except Exception as e:
        _LOG.warning(
            "crash report shipping failed",
            extra={
                "audit": True,
                "event": "crash.ship_failed",
                "sink_name": sink,
                "error_type": type(e).__name__,
                "error_message": str(e),
            },
        )


def _resolve_active_sinks() -> list[str]:
    """Look at the active AppContext (if any) for the configured
    crash-sink list. Returns ``[]`` when no context active OR the
    settings list is the default ``["local"]`` (signal: 'use the
    legacy env-var path').
    """
    try:
        from mgf.common._lifecycle import current_context
    except ImportError:
        return []
    ctx = current_context()
    if ctx is None:
        return []
    sinks = list(ctx.settings.crash.sinks)
    # When the consumer hasn't customised the list, fall through
    # to the env-var path so legacy behaviour is preserved.
    if sinks == ["local"]:
        return []
    return sinks


@register_crash_sink("sentry")
def _ship_sentry(report_path: Path, report: dict[str, Any]) -> None:
    """Built-in Sentry sink. Pre-registered at module import."""
    try:
        import sentry_sdk
    except ImportError:
        _LOG.warning(
            "crash sink configured but its optional dependency is not installed",
            extra={
                "audit": True,
                "event": "crash.sink_unavailable",
                "sink_name": "sentry",
                "missing_dependency": "sentry-sdk",
                "hint": "pip install 'mgf-common[observability]' (or remove sentry from crash.sinks)",
            },
        )
        return
    # ``capture_event`` accepts a synthetic event dict; we send the
    # full report as the message + extras so Sentry doesn't deduplicate
    # us into oblivion based on the typed exception alone.
    sentry_sdk.capture_event(
        {
            "message": report["exception"]["message"],
            "level": "error",
            "extra": report,
        }
    )


@register_crash_sink("otlp")
def _ship_otlp(report_path: Path, report: dict[str, Any]) -> None:
    """Built-in OTLP log-emitter sink. Pre-registered at module import."""
    try:
        from mgf.common.observability.otel_setup import emit_log_event
    except ImportError:
        _LOG.warning(
            "crash sink configured but its optional dependency is not installed",
            extra={
                "audit": True,
                "event": "crash.sink_unavailable",
                "sink_name": "otlp",
                "missing_dependency": "opentelemetry-sdk",
                "hint": "pip install 'mgf-common[observability]' (or remove otlp from crash.sinks)",
            },
        )
        return
    emit_log_event(name="crash", attributes=report)


def _ship_http(report: dict[str, Any], url: str) -> None:
    """POST the report to a webhook URL.

    Synchronous with a 5-second timeout — fine for CLI exit-code
    translators. For long-running services, callers SHOULD wrap
    ``report_now`` in a queue.
    """
    import urllib.request

    body = json.dumps(report).encode("utf-8")
    # ruff S310 — the URL is operator-controlled (set via the
    # <APP>_CRASH_SINK env var), not derived from user input. The
    # scheme check is the operator's responsibility; restricting to
    # http/https here would silently drop file:/ for local-test
    # mocks. Documented per-line allowlist instead of blanket noqa.
    req = urllib.request.Request(  # noqa: S310
        url,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=5):  # noqa: S310 — see above
        pass


def _crash_dir() -> Path:
    """Deprecated alias for :func:`mgf.common.observability.default_crash_dir`.

    Removed in a future release. Closed-box leak: consumers that
    imported this private helper (it had no public alternative
    before v0.13) should migrate to the public name.
    """
    import warnings

    from mgf.common._warnings import MgfDeprecationWarning

    warnings.warn(
        "mgf.common.observability.crash_reporter._crash_dir is "
        "deprecated since v0.13.0; import "
        "mgf.common.observability.default_crash_dir instead.",
        MgfDeprecationWarning,
        stacklevel=2,
    )
    return default_crash_dir()


def _qualified_type(exc: BaseException) -> str:
    """Return ``module.QualName`` of an exception's type."""
    cls = type(exc)
    return f"{cls.__module__}.{cls.__qualname__}"


__all__ = [
    "CrashSink",
    "register_crash_sink",
    "report_now",
    "unregister_crash_sink",
]
